feat(time_entries): add time_entries app's basic structure and endpoints
This commit is contained in:
33
apps/time_entries/admin.py
Normal file
33
apps/time_entries/admin.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from core.admins.base import BaseAdmin, SoftDeleteListFilter
|
||||||
|
from apps.time_entries.models import TimeEntry
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TimeEntry)
|
||||||
|
class TimeEntryAdmin(BaseAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"user",
|
||||||
|
"workspace",
|
||||||
|
"project",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"is_billable",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
SoftDeleteListFilter,
|
||||||
|
"workspace",
|
||||||
|
"project",
|
||||||
|
"is_billable",
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"user__mobile",
|
||||||
|
"project__name",
|
||||||
|
"description",
|
||||||
|
)
|
||||||
|
autocomplete_fields = (
|
||||||
|
"user",
|
||||||
|
"workspace",
|
||||||
|
"project",
|
||||||
|
)
|
||||||
78
apps/time_entries/api/serializers.py
Normal file
78
apps/time_entries/api/serializers.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from core.serializers.base import BaseModelSerializer
|
||||||
|
from apps.time_entries.models import TimeEntry
|
||||||
|
from apps.projects.models import Project
|
||||||
|
from apps.tags.models import Tag
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntrySerializer(BaseModelSerializer):
|
||||||
|
"""
|
||||||
|
Output serializer for TimeEntry.
|
||||||
|
"""
|
||||||
|
class Meta:
|
||||||
|
model = TimeEntry
|
||||||
|
fields = BaseModelSerializer.Meta.fields + (
|
||||||
|
"workspace",
|
||||||
|
"user",
|
||||||
|
"project",
|
||||||
|
"description",
|
||||||
|
"start_time",
|
||||||
|
"end_time",
|
||||||
|
"duration",
|
||||||
|
"tags",
|
||||||
|
"is_billable",
|
||||||
|
"hourly_rate",
|
||||||
|
"currency",
|
||||||
|
)
|
||||||
|
read_only_fields = fields
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntryCreateSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Validates input data for creating/starting a time entry.
|
||||||
|
"""
|
||||||
|
workspace_id = serializers.UUIDField()
|
||||||
|
project_id = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Project.objects.filter(is_deleted=False),
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
source='project'
|
||||||
|
)
|
||||||
|
start_time = serializers.DateTimeField()
|
||||||
|
end_time = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
|
description = serializers.CharField(required=False, allow_blank=True, default="")
|
||||||
|
tags = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Tag.objects.filter(is_deleted=False),
|
||||||
|
many=True,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
is_billable = serializers.BooleanField(default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntryUpdateSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Validates input data for updating an existing time entry.
|
||||||
|
"""
|
||||||
|
project_id = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Project.objects.filter(is_deleted=False),
|
||||||
|
required=False,
|
||||||
|
allow_null=True,
|
||||||
|
source='project'
|
||||||
|
)
|
||||||
|
start_time = serializers.DateTimeField(required=False)
|
||||||
|
end_time = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
|
description = serializers.CharField(required=False, allow_blank=True)
|
||||||
|
tags = serializers.PrimaryKeyRelatedField(
|
||||||
|
queryset=Tag.objects.filter(is_deleted=False),
|
||||||
|
many=True,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
is_billable = serializers.BooleanField(required=False)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntryStopSerializer(serializers.Serializer):
|
||||||
|
"""
|
||||||
|
Optional specific serializer for stopping a timer manually.
|
||||||
|
"""
|
||||||
|
end_time = serializers.DateTimeField(required=False, allow_null=True)
|
||||||
13
apps/time_entries/api/urls.py
Normal file
13
apps/time_entries/api/urls.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from apps.time_entries.api.views import TimeEntryViewSet
|
||||||
|
|
||||||
|
app_name = "time_entries"
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r"time-entries", TimeEntryViewSet, basename="time-entry")
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", include(router.urls)),
|
||||||
|
]
|
||||||
113
apps/time_entries/api/views.py
Normal file
113
apps/time_entries/api/views.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
from rest_framework import status
|
||||||
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.filters import SearchFilter, OrderingFilter
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
|
|
||||||
|
from core.paginations.limit_offset import CustomLimitOffsetPagination
|
||||||
|
|
||||||
|
from apps.time_entries.models import TimeEntry
|
||||||
|
from apps.time_entries.api.serializers import (
|
||||||
|
TimeEntrySerializer,
|
||||||
|
TimeEntryCreateSerializer,
|
||||||
|
TimeEntryUpdateSerializer,
|
||||||
|
TimeEntryStopSerializer
|
||||||
|
)
|
||||||
|
from apps.time_entries.services.time_entries import (
|
||||||
|
create_time_entry,
|
||||||
|
update_time_entry,
|
||||||
|
stop_time_entry
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntryViewSet(ModelViewSet):
|
||||||
|
"""
|
||||||
|
API endpoints for managing time entries.
|
||||||
|
"""
|
||||||
|
pagination_class = CustomLimitOffsetPagination
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter]
|
||||||
|
filterset_fields = ["workspace", "project", "is_billable"]
|
||||||
|
search_fields = ["description", "project__name", "tags__name"]
|
||||||
|
ordering_fields = ["start_time", "end_time", "created_at", "updated_at"]
|
||||||
|
ordering = ["-start_time"]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""
|
||||||
|
Users can only interact with their own time entries within workspaces
|
||||||
|
where they hold an active membership.
|
||||||
|
"""
|
||||||
|
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
|
||||||
|
return TimeEntry.objects.none()
|
||||||
|
|
||||||
|
return TimeEntry.objects.filter(
|
||||||
|
user=self.request.user,
|
||||||
|
workspace__memberships__user=self.request.user,
|
||||||
|
workspace__memberships__is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == "create":
|
||||||
|
return TimeEntryCreateSerializer
|
||||||
|
elif self.action in ["update", "partial_update"]:
|
||||||
|
return TimeEntryUpdateSerializer
|
||||||
|
elif self.action == "stop":
|
||||||
|
return TimeEntryStopSerializer
|
||||||
|
return TimeEntrySerializer
|
||||||
|
|
||||||
|
def create(self, request, *args, **kwargs):
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
entry = create_time_entry(
|
||||||
|
user=request.user,
|
||||||
|
workspace_id=serializer.validated_data.pop("workspace_id"),
|
||||||
|
**serializer.validated_data
|
||||||
|
)
|
||||||
|
|
||||||
|
output_serializer = TimeEntrySerializer(entry)
|
||||||
|
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def update(self, request, *args, **kwargs):
|
||||||
|
partial = kwargs.pop("partial", False)
|
||||||
|
entry = self.get_object()
|
||||||
|
|
||||||
|
serializer = self.get_serializer(data=request.data, partial=partial)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
updated_entry = update_time_entry(
|
||||||
|
entry=entry,
|
||||||
|
**serializer.validated_data
|
||||||
|
)
|
||||||
|
|
||||||
|
output_serializer = TimeEntrySerializer(updated_entry)
|
||||||
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
@action(detail=True, methods=["post"])
|
||||||
|
def stop(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Dedicated endpoint to stop an actively running timer.
|
||||||
|
"""
|
||||||
|
entry = self.get_object()
|
||||||
|
|
||||||
|
serializer = self.get_serializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
end_time = serializer.validated_data.get("end_time")
|
||||||
|
stopped_entry = stop_time_entry(entry, end_time=end_time)
|
||||||
|
|
||||||
|
output_serializer = TimeEntrySerializer(stopped_entry)
|
||||||
|
return Response(output_serializer.data, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def destroy(self, request, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Soft deletes the time entry.
|
||||||
|
"""
|
||||||
|
entry = self.get_object()
|
||||||
|
entry.is_deleted = True
|
||||||
|
entry.save(update_fields=["is_deleted", "updated_at"])
|
||||||
|
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||||
7
apps/time_entries/apps.py
Normal file
7
apps/time_entries/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntriesConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.time_entries"
|
||||||
|
verbose_name = "Time Entries"
|
||||||
0
apps/time_entries/migrations/__init__.py
Normal file
0
apps/time_entries/migrations/__init__.py
Normal file
81
apps/time_entries/models.py
Normal file
81
apps/time_entries/models.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
|
from core.models.base import BaseModel
|
||||||
|
from apps.workspaces.models import Workspace
|
||||||
|
from apps.projects.models import Project
|
||||||
|
from apps.tags.models import Tag
|
||||||
|
|
||||||
|
|
||||||
|
User = settings.AUTH_USER_MODEL
|
||||||
|
|
||||||
|
|
||||||
|
class TimeEntry(BaseModel):
|
||||||
|
workspace = models.ForeignKey(
|
||||||
|
Workspace,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="time_entries",
|
||||||
|
)
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name="time_entries",
|
||||||
|
)
|
||||||
|
project = models.ForeignKey(
|
||||||
|
Project,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name="time_entries",
|
||||||
|
)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
start_time = models.DateTimeField()
|
||||||
|
end_time = models.DateTimeField(null=True, blank=True)
|
||||||
|
duration = models.DurationField(null=True, blank=True)
|
||||||
|
tags = models.ManyToManyField(
|
||||||
|
Tag,
|
||||||
|
blank=True,
|
||||||
|
related_name="time_entries",
|
||||||
|
)
|
||||||
|
is_billable = models.BooleanField(default=False)
|
||||||
|
hourly_rate = models.DecimalField(
|
||||||
|
max_digits=10,
|
||||||
|
decimal_places=2,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
currency = models.CharField(
|
||||||
|
max_length=3,
|
||||||
|
default="USD",
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "time_entry"
|
||||||
|
ordering = ("-updated_at", "-created_at")
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["workspace"], name="time_entry_workspace_idx"),
|
||||||
|
models.Index(fields=["user"], name="time_entry_user_idx"),
|
||||||
|
models.Index(fields=["project"], name="time_entry_project_idx"),
|
||||||
|
models.Index(fields=["start_time"], name="time_entry_start_idx"),
|
||||||
|
models.Index(fields=["workspace", "start_time"], name="time_entry_workspace_start_idx"),
|
||||||
|
]
|
||||||
|
constraints = [
|
||||||
|
models.UniqueConstraint(
|
||||||
|
fields=["workspace", "user"],
|
||||||
|
condition=Q(end_time__isnull=True, is_deleted=False),
|
||||||
|
name="unique_running_timer_per_user",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} - {self.start_time}"
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
if self.project and self.project.workspace_id != self.workspace_id:
|
||||||
|
raise ValidationError("Project must belong to the same workspace.")
|
||||||
|
|
||||||
|
for tag in self.tags.all():
|
||||||
|
if tag.workspace_id != self.workspace_id:
|
||||||
|
raise ValidationError("Tags must belong to the same workspace.")
|
||||||
22
apps/time_entries/services/rates.py
Normal file
22
apps/time_entries/services/rates.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
from apps.projects.models import ProjectRate, ProjectUserRate
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_rate(user, project):
|
||||||
|
user_rate = ProjectUserRate.objects.filter(
|
||||||
|
user=user,
|
||||||
|
project=project,
|
||||||
|
is_active=True,
|
||||||
|
).order_by("-effective_from").first()
|
||||||
|
|
||||||
|
if user_rate:
|
||||||
|
return user_rate.hourly_rate, user_rate.currency
|
||||||
|
|
||||||
|
project_rate = ProjectRate.objects.filter(
|
||||||
|
project=project,
|
||||||
|
is_active=True,
|
||||||
|
).order_by("-effective_from").first()
|
||||||
|
|
||||||
|
if project_rate:
|
||||||
|
return project_rate.hourly_rate, project_rate.currency
|
||||||
|
|
||||||
|
return None, "USD"
|
||||||
141
apps/time_entries/services/time_entries.py
Normal file
141
apps/time_entries/services/time_entries.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.exceptions import ValidationError, PermissionDenied
|
||||||
|
|
||||||
|
from apps.time_entries.models import TimeEntry
|
||||||
|
from apps.time_entries.services.rates import resolve_rate
|
||||||
|
from apps.workspaces.models import WorkspaceMembership
|
||||||
|
|
||||||
|
|
||||||
|
def _verify_workspace_access(user, workspace_id):
|
||||||
|
"""
|
||||||
|
Ensures the user is an active member of the specified workspace.
|
||||||
|
"""
|
||||||
|
has_access = WorkspaceMembership.objects.filter(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
user=user,
|
||||||
|
is_active=True,
|
||||||
|
is_deleted=False
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
if not has_access:
|
||||||
|
raise PermissionDenied("You do not have access to this workspace.")
|
||||||
|
|
||||||
|
|
||||||
|
def create_time_entry(user, workspace_id, start_time, end_time=None, project=None, tags=None, description="", is_billable=False):
|
||||||
|
"""
|
||||||
|
Creates a new time entry. If end_time is None, it acts as a running timer.
|
||||||
|
"""
|
||||||
|
_verify_workspace_access(user, workspace_id)
|
||||||
|
|
||||||
|
if not end_time:
|
||||||
|
has_running_timer = TimeEntry.objects.filter(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
user=user,
|
||||||
|
end_time__isnull=True,
|
||||||
|
is_deleted=False
|
||||||
|
).exists()
|
||||||
|
if has_running_timer:
|
||||||
|
raise ValidationError({"non_field_errors": "You already have a running timer in this workspace."})
|
||||||
|
|
||||||
|
if start_time and end_time and start_time >= end_time:
|
||||||
|
raise ValidationError({"end_time": "End time must be strictly after start time."})
|
||||||
|
|
||||||
|
if project and project.workspace_id != workspace_id:
|
||||||
|
raise ValidationError({"project": "Project must belong to the same workspace."})
|
||||||
|
|
||||||
|
duration = (end_time - start_time) if end_time else None
|
||||||
|
|
||||||
|
hourly_rate, currency = None, "USD"
|
||||||
|
if is_billable and project:
|
||||||
|
hourly_rate, currency = resolve_rate(user, project)
|
||||||
|
|
||||||
|
entry = TimeEntry.objects.create(
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
user=user,
|
||||||
|
project=project,
|
||||||
|
description=description,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
duration=duration,
|
||||||
|
is_billable=is_billable,
|
||||||
|
hourly_rate=hourly_rate,
|
||||||
|
currency=currency
|
||||||
|
)
|
||||||
|
|
||||||
|
if tags:
|
||||||
|
for tag in tags:
|
||||||
|
if tag.workspace_id != workspace_id:
|
||||||
|
raise ValidationError({"tags": f"Tag '{tag.name}' does not belong to the workspace."})
|
||||||
|
entry.tags.set(tags)
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def update_time_entry(entry, **kwargs):
|
||||||
|
"""
|
||||||
|
Updates an existing time entry, recalculating duration and rates if necessary.
|
||||||
|
"""
|
||||||
|
# Verify Project Workspace if changing
|
||||||
|
project = kwargs.get("project", entry.project)
|
||||||
|
if project and project.workspace_id != entry.workspace_id:
|
||||||
|
raise ValidationError({"project": "Project must belong to the same workspace."})
|
||||||
|
|
||||||
|
start_time = kwargs.get("start_time", entry.start_time)
|
||||||
|
end_time = kwargs.get("end_time", entry.end_time)
|
||||||
|
|
||||||
|
# Check if attempting to resume a timer (setting end_time to null)
|
||||||
|
if "end_time" in kwargs and end_time is None and entry.end_time is not None:
|
||||||
|
has_running_timer = TimeEntry.objects.filter(
|
||||||
|
workspace_id=entry.workspace_id,
|
||||||
|
user=entry.user,
|
||||||
|
end_time__isnull=True,
|
||||||
|
is_deleted=False
|
||||||
|
).exclude(id=entry.id).exists()
|
||||||
|
if has_running_timer:
|
||||||
|
raise ValidationError({"non_field_errors": "You already have a running timer in this workspace."})
|
||||||
|
|
||||||
|
if start_time and end_time and start_time >= end_time:
|
||||||
|
raise ValidationError({"end_time": "End time must be strictly after start time."})
|
||||||
|
|
||||||
|
kwargs["duration"] = (end_time - start_time) if end_time else None
|
||||||
|
|
||||||
|
# Recalculate rates if billing status or project changes
|
||||||
|
is_billable = kwargs.get("is_billable", entry.is_billable)
|
||||||
|
if "project" in kwargs or "is_billable" in kwargs:
|
||||||
|
if is_billable and project:
|
||||||
|
kwargs["hourly_rate"], kwargs["currency"] = resolve_rate(entry.user, project)
|
||||||
|
else:
|
||||||
|
kwargs["hourly_rate"] = None
|
||||||
|
|
||||||
|
tags = kwargs.pop("tags", None)
|
||||||
|
update_fields = []
|
||||||
|
|
||||||
|
for field, value in kwargs.items():
|
||||||
|
if hasattr(entry, field) and getattr(entry, field) != value:
|
||||||
|
setattr(entry, field, value)
|
||||||
|
update_fields.append(field)
|
||||||
|
|
||||||
|
if update_fields:
|
||||||
|
update_fields.append("updated_at")
|
||||||
|
entry.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
# Handle tags update
|
||||||
|
if tags is not None:
|
||||||
|
for tag in tags:
|
||||||
|
if tag.workspace_id != entry.workspace_id:
|
||||||
|
raise ValidationError({"tags": f"Tag '{tag.name}' does not belong to the workspace."})
|
||||||
|
entry.tags.set(tags)
|
||||||
|
|
||||||
|
return entry
|
||||||
|
|
||||||
|
|
||||||
|
def stop_time_entry(entry, end_time=None):
|
||||||
|
"""
|
||||||
|
Helper function specifically for stopping an active timer.
|
||||||
|
"""
|
||||||
|
if entry.end_time is not None:
|
||||||
|
raise ValidationError({"non_field_errors": "This time entry is already stopped."})
|
||||||
|
|
||||||
|
stop_time = end_time or timezone.now()
|
||||||
|
return update_time_entry(entry, end_time=stop_time)
|
||||||
@@ -44,6 +44,7 @@ LOCAL_APPS = [
|
|||||||
"apps.clients",
|
"apps.clients",
|
||||||
"apps.projects",
|
"apps.projects",
|
||||||
"apps.tags",
|
"apps.tags",
|
||||||
|
"apps.time_entries",
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ urlpatterns = [
|
|||||||
path('api/', include('apps.clients.api.urls'), name="clients"),
|
path('api/', include('apps.clients.api.urls'), name="clients"),
|
||||||
path('api/', include('apps.projects.api.urls'), name="projects"),
|
path('api/', include('apps.projects.api.urls'), name="projects"),
|
||||||
path('api/', include('apps.tags.api.urls'), name="tags"),
|
path('api/', include('apps.tags.api.urls'), name="tags"),
|
||||||
|
path('api/', include('apps.time_entries.api.urls'), name="time_entries"),
|
||||||
]
|
]
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|||||||
Reference in New Issue
Block a user