diff --git a/manage.py b/manage.py
index 079cfdc..d1e3cf8 100755
--- a/manage.py
+++ b/manage.py
@@ -1,8 +1,7 @@
#!/usr/bin/env python
-"""generated with djinit"""
-
"""Django's command-line utility for administrative tasks."""
+
import os
import sys
diff --git a/pyproject.toml b/pyproject.toml
index 861f281..263312e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,6 +16,7 @@ dependencies = [
"django-components>=0.143.2",
"django-htmx>=1.27.0",
"django-nepali-datetime-field>=0.8.0",
+ "django-phonenumber-field[phonenumbers]>=8.4.0",
]
[tool.ruff]
@@ -39,3 +40,8 @@ ignore = [
[tool.ruff.lint.isort]
known-first-party = ["."]
+
+[dependency-groups]
+dev = [
+ "ruff>=0.14.8",
+]
diff --git a/requirements.txt b/requirements.txt
index 66d10a8..d320aa0 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -12,4 +12,8 @@ django-cors-headers
whitenoise
mysqlclient
gunicorn
-dj-database-url
\ No newline at end of file
+dj-database-url
+django-components
+django-htmx
+nepali-datetime-field
+django-phonenumber-field[phonenumbers]
\ No newline at end of file
diff --git a/src/admin/__init__.py b/src/admin/__init__.py
index dc9bfc6..134b769 100644
--- a/src/admin/__init__.py
+++ b/src/admin/__init__.py
@@ -1,5 +1,10 @@
"""generated with djinit"""
-from django.contrib import admin
-
-# Register your models here.
+# Import all admin modules to register them with the custom admin site
+from src.admin import ( # noqa: F401
+ clients,
+ finance,
+ productivity,
+ projects,
+ services,
+)
diff --git a/src/admin/base.py b/src/admin/base.py
index e69de29..1d66ce0 100644
--- a/src/admin/base.py
+++ b/src/admin/base.py
@@ -0,0 +1,137 @@
+from django.contrib.admin import AdminSite
+from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.models import User
+
+
+class DevSuiteAdminSite(AdminSite):
+ site_header = "DevSuite Admin"
+ site_title = "DevSuite Admin Portal"
+ index_title = "Welcome to DevSuite Administration"
+
+ def get_app_list(self, request, app_label=None):
+ """
+ Override to organize models into logical groups.
+ """
+ app_list = super().get_app_list(request, app_label)
+
+ model_map = {
+ # Clients
+ "Client": {
+ "group": "Clients",
+ "display_name": "Clients",
+ "order": 1,
+ },
+ # Finance
+ "Invoice": {
+ "group": "Finance",
+ "display_name": "Invoices",
+ "order": 1,
+ },
+ "Expense": {
+ "group": "Finance",
+ "display_name": "Expenses",
+ "order": 2,
+ },
+ "Payment": {
+ "group": "Finance",
+ "display_name": "Payments",
+ "order": 3,
+ },
+ # Productivity
+ "Note": {
+ "group": "Productivity",
+ "display_name": "Notes",
+ "order": 1,
+ },
+ "TimeEntry": {
+ "group": "Productivity",
+ "display_name": "Time Entries",
+ "order": 2,
+ },
+ # Projects
+ "Project": {
+ "group": "Projects",
+ "display_name": "Projects",
+ "order": 1,
+ },
+ "Milestone": {
+ "group": "Projects",
+ "display_name": "Milestones",
+ "order": 2,
+ },
+ "Task": {
+ "group": "Projects",
+ "display_name": "Tasks",
+ "order": 3,
+ },
+ # Services
+ "Service": {
+ "group": "Services",
+ "display_name": "Services",
+ "order": 1,
+ },
+ "Credential": {
+ "group": "Services",
+ "display_name": "Credentials",
+ "order": 2,
+ },
+ # System & Users
+ "User": {
+ "group": "System & Users",
+ "display_name": "Admin Users",
+ "order": 1,
+ },
+ "Group": {
+ "group": "System & Users",
+ "display_name": "Groups",
+ "order": 2,
+ },
+ }
+
+ # Group models according to the model_map
+ grouped_models = {}
+ for app in app_list:
+ for model in app["models"]:
+ model_name = model["object_name"]
+ if model_name in model_map:
+ config = model_map[model_name]
+ group = config["group"]
+
+ if group not in grouped_models:
+ grouped_models[group] = {
+ "name": group,
+ "app_label": group.lower()
+ .replace(" ", "_")
+ .replace("&", "and"),
+ "models": [],
+ }
+
+ # Use custom display name
+ model["name"] = config["display_name"]
+ model["_order"] = config["order"]
+ grouped_models[group]["models"].append(model)
+
+ # Sort models within each group by their order
+ for group_dict in grouped_models.values():
+ group_dict["models"].sort(key=lambda m: m.get("_order", 999))
+
+ # Define the order of groups (most frequently accessed first)
+ group_order = [
+ "Clients",
+ "Projects",
+ "Services",
+ "Finance",
+ "Productivity",
+ "System & Users",
+ ]
+
+ # Build final app list in the defined order
+ grouped_app_list = [
+ grouped_models[g] for g in group_order if g in grouped_models
+ ]
+
+ return grouped_app_list
+
+
+admin_site = DevSuiteAdminSite(name="ssgd_admin")
+admin_site.register(User, UserAdmin)
diff --git a/src/admin/clients.py b/src/admin/clients.py
index e69de29..22ea5ac 100644
--- a/src/admin/clients.py
+++ b/src/admin/clients.py
@@ -0,0 +1,73 @@
+from django.contrib import admin
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+from src.admin.base import admin_site
+from src.models.clients import Client
+
+
+@admin.register(Client, site=admin_site)
+class ClientAdmin(admin.ModelAdmin):
+ list_display = (
+ "name_display",
+ "short_code",
+ "company_name",
+ "email",
+ "phone",
+ "created_at",
+ "updated_at",
+ )
+ list_display_links = ("name_display",)
+ list_per_page = 25
+
+ search_fields = ("name", "short_code", "email", "phone", "company_name")
+ list_filter = ("created_at", "updated_at")
+ ordering = ("name",)
+
+ fieldsets = (
+ (
+ _("Basic Info"),
+ {
+ "fields": ("name", "short_code", "company_name"),
+ "description": "Client’s identifying information. The short code is auto-generated if left blank.",
+ },
+ ),
+ (
+ _("Contact Details"),
+ {
+ "fields": ("email", "phone", "address"),
+ },
+ ),
+ (
+ _("Timestamps"),
+ {
+ "fields": ("created_at", "updated_at"),
+ "classes": ("collapse",), # collapsible section for cleaner UI
+ },
+ ),
+ )
+
+ readonly_fields = ("created_at", "updated_at")
+
+ def name_display(self, obj):
+ """Display name with subtle emphasis."""
+ return format_html(
+ "{}
{}",
+ obj.name,
+ obj.short_code or "—",
+ )
+
+ name_display.short_description = "Client"
+
+ def save_model(self, request, obj, form, change):
+ """
+ Ensure short_code generation happens gracefully and show a message.
+ """
+ if not obj.short_code:
+ generated = obj.generate_short_code()
+ obj.short_code = generated
+ self.message_user(
+ request,
+ f"Short code '{generated}' was automatically generated for client '{obj.name}'.",
+ )
+ super().save_model(request, obj, form, change)
diff --git a/src/admin/finance.py b/src/admin/finance.py
index e69de29..bea461a 100644
--- a/src/admin/finance.py
+++ b/src/admin/finance.py
@@ -0,0 +1,196 @@
+from django.contrib import admin
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+from src.admin.base import admin_site
+from src.models.finance import Expense, Invoice, Payment
+
+
+class PaymentInline(admin.TabularInline):
+ model = Payment
+ extra = 1
+ fields = ("amount", "date", "method", "transaction_id")
+ readonly_fields = ("transaction_id",)
+ show_change_link = True
+ verbose_name_plural = "Payments"
+
+
+@admin.register(Invoice, site=admin_site)
+class InvoiceAdmin(admin.ModelAdmin):
+ list_display = (
+ "invoice_number",
+ "client_display",
+ "project_display",
+ "amount_display",
+ "status_badge",
+ "date_issued",
+ "due_date",
+ )
+ list_display_links = ("invoice_number",)
+ list_filter = ("status", "date_issued", "due_date")
+ search_fields = (
+ "invoice_number",
+ "client__name",
+ "client__short_code",
+ "project__name",
+ )
+ ordering = ("-date_issued",)
+ list_per_page = 25
+ inlines = [PaymentInline]
+
+ fieldsets = (
+ (
+ _("Invoice Details"),
+ {
+ "fields": (
+ "client",
+ "project",
+ "invoice_number",
+ "amount",
+ "status",
+ ),
+ "description": "Core invoice information. The invoice number is auto-generated upon creation.",
+ },
+ ),
+ (
+ _("Dates"),
+ {
+ "fields": ("date_issued", "due_date"),
+ "classes": ("collapse",),
+ },
+ ),
+ (
+ _("Metadata"),
+ {
+ "fields": ("created_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+ readonly_fields = (
+ "invoice_number",
+ "date_issued",
+ "due_date",
+ "created_at",
+ "updated_at",
+ )
+
+ def client_display(self, obj):
+ return format_html(
+ "{}
{}",
+ obj.client.name,
+ obj.client.short_code,
+ )
+
+ client_display.short_description = "Client"
+
+ def project_display(self, obj):
+ return obj.project.name
+
+ project_display.short_description = "Project"
+
+ def amount_display(self, obj):
+ return format_html("${:,.2f}", obj.amount)
+
+ amount_display.short_description = "Amount"
+
+ def status_badge(self, obj):
+ color_map = {
+ "DRAFT": "#999",
+ "SENT": "#0066cc",
+ "PAID": "#28a745",
+ "OVERDUE": "#dc3545",
+ "CANCELLED": "#6c757d",
+ }
+ color = color_map.get(obj.status, "#333")
+ return format_html(
+ "{}",
+ color,
+ obj.get_status_display(),
+ )
+
+ status_badge.short_description = "Status"
+
+ def save_model(self, request, obj, form, change):
+ if not obj.invoice_number:
+ obj.save() # triggers auto-generation
+ self.message_user(
+ request,
+ f"Invoice number {obj.invoice_number} generated for {obj.client}.",
+ )
+ else:
+ super().save_model(request, obj, form, change)
+
+
+@admin.register(Expense, site=admin_site)
+class ExpenseAdmin(admin.ModelAdmin):
+ list_display = (
+ "description",
+ "category",
+ "amount_display",
+ "service",
+ "date",
+ "created_at",
+ )
+ list_filter = ("category", "date", "created_at")
+ search_fields = ("description", "category", "service__name")
+ ordering = ("-date",)
+
+ fieldsets = (
+ (
+ _("Expense Info"),
+ {
+ "fields": ("service", "description", "category", "amount", "date"),
+ },
+ ),
+ (
+ _("Metadata"),
+ {
+ "fields": ("created_at",),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+ readonly_fields = ("created_at",)
+
+ def amount_display(self, obj):
+ return format_html("${:,.2f}", obj.amount)
+
+ amount_display.short_description = "Amount"
+
+
+@admin.register(Payment, site=admin_site)
+class PaymentAdmin(admin.ModelAdmin):
+ list_display = (
+ "invoice_display",
+ "amount_display",
+ "date",
+ "method",
+ "transaction_id",
+ )
+ list_filter = ("method", "date")
+ search_fields = ("invoice__invoice_number", "transaction_id", "method")
+ ordering = ("-date",)
+
+ fieldsets = (
+ (
+ _("Payment Info"),
+ {
+ "fields": ("invoice", "amount", "date", "method", "transaction_id"),
+ },
+ ),
+ )
+
+ def invoice_display(self, obj):
+ return format_html(
+ "{}
{}",
+ obj.invoice.invoice_number,
+ obj.invoice.client.name,
+ )
+
+ invoice_display.short_description = "Invoice"
+
+ def amount_display(self, obj):
+ return format_html("${:,.2f}", obj.amount)
+
+ amount_display.short_description = "Amount"
diff --git a/src/admin/productivity.py b/src/admin/productivity.py
index e69de29..caef48c 100644
--- a/src/admin/productivity.py
+++ b/src/admin/productivity.py
@@ -0,0 +1,133 @@
+from django.contrib import admin
+from django.utils.html import format_html
+from django.utils.timezone import localtime
+from django.utils.translation import gettext_lazy as _
+
+from src.admin.base import admin_site
+from src.models.productivity import Note, TimeEntry
+
+
+@admin.register(Note, site=admin_site)
+class NoteAdmin(admin.ModelAdmin):
+ list_display = ("title_display", "created_at", "updated_at")
+ search_fields = ("title", "content")
+ list_filter = ("created_at", "updated_at")
+ ordering = ("-created_at",)
+ list_per_page = 25
+
+ fieldsets = (
+ (
+ _("Note Details"),
+ {
+ "fields": ("title", "content"),
+ },
+ ),
+ (
+ _("Timestamps"),
+ {
+ "fields": ("created_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+ readonly_fields = ("created_at", "updated_at")
+
+ def title_display(self, obj):
+ """Show truncated title for clarity."""
+ return format_html("{}", obj.title[:60])
+
+ title_display.short_description = "Title"
+
+
+@admin.register(TimeEntry, site=admin_site)
+class TimeEntryAdmin(admin.ModelAdmin):
+ list_display = (
+ "description_display",
+ "project_display",
+ "task_display",
+ "start_time_display",
+ "end_time_display",
+ "duration_display",
+ )
+ list_filter = (
+ ("project", admin.RelatedOnlyFieldListFilter),
+ ("task", admin.RelatedOnlyFieldListFilter),
+ "start_time",
+ "end_time",
+ )
+ search_fields = ("description", "project__name", "task__name")
+ ordering = ("-start_time",)
+ list_per_page = 25
+
+ fieldsets = (
+ (
+ _("Work Details"),
+ {
+ "fields": ("project", "task", "description"),
+ },
+ ),
+ (
+ _("Timing"),
+ {
+ "fields": ("start_time", "end_time", "duration"),
+ "description": "Duration is automatically calculated when both start and end times are provided.",
+ },
+ ),
+ )
+ readonly_fields = ("duration",)
+
+ def description_display(self, obj):
+ return format_html("{}", obj.description[:50])
+
+ description_display.short_description = "Description"
+
+ def project_display(self, obj):
+ return obj.project.name if obj.project else "—"
+
+ project_display.short_description = "Project"
+
+ def task_display(self, obj):
+ return obj.task.name if obj.task else "—"
+
+ task_display.short_description = "Task"
+
+ def start_time_display(self, obj):
+ return (
+ localtime(obj.start_time).strftime("%Y-%m-%d %H:%M")
+ if obj.start_time
+ else "—"
+ )
+
+ start_time_display.short_description = "Start"
+
+ def end_time_display(self, obj):
+ if obj.end_time:
+ return localtime(obj.end_time).strftime("%Y-%m-%d %H:%M")
+ return format_html("In Progress")
+
+ end_time_display.short_description = "End"
+
+ def duration_display(self, obj):
+ if obj.duration:
+ hours = obj.duration.total_seconds() / 3600
+ color = "#28a745" if hours < 8 else "#f39c12"
+ return format_html(
+ "{:.2f} hrs",
+ color,
+ hours,
+ )
+ return format_html("—")
+
+ duration_display.short_description = "Duration"
+
+ def save_model(self, request, obj, form, change):
+ obj.save() # triggers auto duration calculation
+ if obj.duration:
+ hours = round(obj.duration.total_seconds() / 3600, 2)
+ self.message_user(
+ request, f"Duration automatically calculated: {hours} hours."
+ )
+ else:
+ self.message_user(
+ request, "Duration not calculated (in progress or missing end time)."
+ )
diff --git a/src/admin/projects.py b/src/admin/projects.py
index e69de29..5383ade 100644
--- a/src/admin/projects.py
+++ b/src/admin/projects.py
@@ -0,0 +1,233 @@
+from django.contrib import admin
+from django.utils import timezone
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+from src.admin.base import admin_site
+from src.models.projects import Milestone, Project, Task
+
+
+class MilestoneInline(admin.TabularInline):
+ model = Milestone
+ extra = 1
+ fields = ("title", "due_date", "is_completed")
+ show_change_link = True
+ ordering = ("due_date",)
+ verbose_name_plural = "Milestones"
+
+
+class TaskInline(admin.TabularInline):
+ model = Task
+ extra = 1
+ fields = ("title", "status", "due_date")
+ show_change_link = True
+ ordering = ("status", "due_date")
+ verbose_name_plural = "Tasks"
+
+
+@admin.register(Project, site=admin_site)
+class ProjectAdmin(admin.ModelAdmin):
+ list_display = (
+ "name_display",
+ "client_display",
+ "status_badge",
+ "start_date",
+ "deadline",
+ "budget_display",
+ )
+ list_filter = ("status", "client", "start_date", "deadline")
+ search_fields = ("name", "client__name", "description")
+ ordering = ("-created_at",)
+ list_per_page = 25
+ inlines = [MilestoneInline, TaskInline]
+
+ fieldsets = (
+ (
+ _("Project Overview"),
+ {
+ "fields": ("client", "name", "description", "status"),
+ },
+ ),
+ (
+ _("Timeline"),
+ {
+ "fields": ("start_date", "deadline"),
+ "description": "Start and deadline dates help in tracking project progress.",
+ },
+ ),
+ (
+ _("Financials"),
+ {
+ "fields": ("budget",),
+ },
+ ),
+ (
+ _("Timestamps"),
+ {
+ "fields": ("created_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+
+ readonly_fields = ("created_at", "updated_at")
+
+ def name_display(self, obj):
+ return format_html("{}", obj.name)
+
+ name_display.short_description = "Project"
+
+ def client_display(self, obj):
+ return obj.client.name
+
+ client_display.short_description = "Client"
+
+ def status_badge(self, obj):
+ color_map = {
+ "PLANNING": "#6c757d",
+ "IN_PROGRESS": "#007bff",
+ "ON_HOLD": "#ffc107",
+ "COMPLETED": "#28a745",
+ "CANCELLED": "#dc3545",
+ }
+ color = color_map.get(obj.status, "#999")
+ return format_html(
+ "{}",
+ color,
+ obj.get_status_display(),
+ )
+
+ status_badge.short_description = "Status"
+
+ def budget_display(self, obj):
+ return format_html("${:,.2f}", obj.budget) if obj.budget else "—"
+
+ budget_display.short_description = "Budget"
+
+ def save_model(self, request, obj, form, change):
+ if obj.deadline and obj.start_date and obj.deadline < obj.start_date:
+ self.message_user(
+ request,
+ "⚠️ Deadline cannot be earlier than start date.",
+ level="warning",
+ )
+ super().save_model(request, obj, form, change)
+
+
+@admin.register(Milestone, site=admin_site)
+class MilestoneAdmin(admin.ModelAdmin):
+ list_display = ("title_display", "project_display", "due_date", "status_badge")
+ list_filter = ("is_completed", "due_date", "project")
+ search_fields = ("title", "project__name")
+ ordering = ("due_date",)
+
+ fieldsets = (
+ (
+ _("Milestone Info"),
+ {
+ "fields": ("project", "title", "due_date", "is_completed"),
+ },
+ ),
+ )
+
+ def title_display(self, obj):
+ return format_html("{}", obj.title)
+
+ title_display.short_description = "Title"
+
+ def project_display(self, obj):
+ return obj.project.name
+
+ project_display.short_description = "Project"
+
+ def status_badge(self, obj):
+ color = "#28a745" if obj.is_completed else "#dc3545"
+ text = "Completed" if obj.is_completed else "Pending"
+ return format_html(
+ "{}",
+ color,
+ text,
+ )
+
+ status_badge.short_description = "Status"
+
+
+@admin.register(Task, site=admin_site)
+class TaskAdmin(admin.ModelAdmin):
+ list_display = (
+ "title_display",
+ "project_display",
+ "status_badge",
+ "due_date_display",
+ )
+ list_filter = ("status", "due_date", "project")
+ search_fields = ("title", "project__name", "description")
+ ordering = ("status", "due_date")
+
+ fieldsets = (
+ (
+ _("Task Info"),
+ {
+ "fields": ("project", "title", "description", "status"),
+ },
+ ),
+ (
+ _("Schedule"),
+ {
+ "fields": ("due_date",),
+ "description": "Due date cannot be in the past.",
+ },
+ ),
+ (
+ _("Timestamps"),
+ {
+ "fields": ("created_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+ readonly_fields = ("created_at", "updated_at")
+
+ def title_display(self, obj):
+ return format_html("{}", obj.title)
+
+ title_display.short_description = "Title"
+
+ def project_display(self, obj):
+ return obj.project.name
+
+ project_display.short_description = "Project"
+
+ def due_date_display(self, obj):
+ if not obj.due_date:
+ return format_html("No due date")
+ elif obj.due_date < timezone.localdate():
+ return format_html("{}", obj.due_date)
+ elif obj.due_date == timezone.localdate():
+ return format_html("Today")
+ return obj.due_date
+
+ due_date_display.short_description = "Due Date"
+
+ def status_badge(self, obj):
+ color_map = {
+ "TODO": "#6c757d",
+ "IN_PROGRESS": "#007bff",
+ "REVIEW": "#ffc107",
+ "DONE": "#28a745",
+ }
+ color = color_map.get(obj.status, "#333")
+ return format_html(
+ "{}",
+ color,
+ obj.get_status_display(),
+ )
+
+ status_badge.short_description = "Status"
+
+ def save_model(self, request, obj, form, change):
+ if obj.due_date and obj.due_date < timezone.localdate():
+ self.message_user(
+ request, "⚠️ Due date cannot be in the past.", level="warning"
+ )
+ super().save_model(request, obj, form, change)
diff --git a/src/admin/services.py b/src/admin/services.py
index e69de29..5a53934 100644
--- a/src/admin/services.py
+++ b/src/admin/services.py
@@ -0,0 +1,217 @@
+from django.contrib import admin
+from django.utils import timezone
+from django.utils.html import format_html
+from django.utils.translation import gettext_lazy as _
+
+from src.admin.base import admin_site
+from src.models.services import Credential, Service
+
+
+class CredentialInline(admin.TabularInline):
+ model = Credential
+ extra = 1
+ fields = ("url", "username", "password", "notes")
+ show_change_link = True
+ verbose_name_plural = "Credentials"
+
+ def get_formset(self, request, obj=None, **kwargs):
+ formset = super().get_formset(request, obj, **kwargs)
+ formset.form.base_fields["password"].widget.attrs["type"] = "password"
+ return formset
+
+
+@admin.register(Service, site=admin_site)
+class ServiceAdmin(admin.ModelAdmin):
+ list_display = (
+ "name_display",
+ "client_display",
+ "type_badge",
+ "provider",
+ "cost_display",
+ "renewal_price_display",
+ "expiry_display",
+ "auto_renew_display",
+ )
+ list_filter = (
+ "service_type",
+ "auto_renew",
+ "provider",
+ "expiry_date",
+ "client",
+ )
+ search_fields = ("name", "client__name", "provider")
+ ordering = ("-created_at",)
+ list_per_page = 25
+ inlines = [CredentialInline]
+
+ fieldsets = (
+ (
+ _("Service Info"),
+ {
+ "fields": ("client", "service_type", "name", "provider"),
+ },
+ ),
+ (
+ _("Billing"),
+ {
+ "fields": ("cost", "renewal_price", "auto_renew"),
+ },
+ ),
+ (
+ _("Duration"),
+ {
+ "fields": ("start_date", "expiry_date"),
+ "description": "Start and expiry dates are used for renewal reminders.",
+ },
+ ),
+ (
+ _("Timestamps"),
+ {
+ "fields": ("created_at", "updated_at"),
+ "classes": ("collapse",),
+ },
+ ),
+ )
+ readonly_fields = ("created_at", "updated_at")
+
+ def name_display(self, obj):
+ return format_html("{}", obj.name)
+
+ name_display.short_description = "Service"
+
+ def client_display(self, obj):
+ return obj.client.name
+
+ client_display.short_description = "Client"
+
+ def type_badge(self, obj):
+ color_map = {
+ "DOMAIN": "#007bff",
+ "HOSTING": "#28a745",
+ "VPS": "#6f42c1",
+ "SSL": "#17a2b8",
+ "OTHER": "#6c757d",
+ }
+ color = color_map.get(obj.service_type, "#333")
+ return format_html(
+ "{}",
+ color,
+ obj.get_service_type_display(),
+ )
+
+ type_badge.short_description = "Type"
+
+ def cost_display(self, obj):
+ return format_html("${:,.2f}", obj.cost)
+
+ cost_display.short_description = "Cost"
+
+ def renewal_price_display(self, obj):
+ return format_html("${:,.2f}", obj.renewal_price)
+
+ renewal_price_display.short_description = "Renewal"
+
+ def expiry_display(self, obj):
+ """Color-coded expiry indicator."""
+ if not obj.expiry_date:
+ return format_html("—")
+ today = timezone.localdate()
+ delta = (obj.expiry_date - today).days
+
+ if delta < 0:
+ color = "#dc3545"
+ label = "Expired"
+ elif delta <= 7:
+ color = "#f39c12"
+ label = f"Expiring ({delta}d)"
+ else:
+ color = "#28a745"
+ label = f"In {delta}d"
+
+ return format_html(
+ "{}
{}",
+ color,
+ label,
+ obj.expiry_date,
+ )
+
+ expiry_display.short_description = "Expiry"
+
+ def auto_renew_display(self, obj):
+ icon = "✅" if obj.auto_renew else "❌"
+ color = "#28a745" if obj.auto_renew else "#999"
+ return format_html(
+ f"{icon}"
+ )
+
+ auto_renew_display.short_description = "Auto Renew"
+
+ def save_model(self, request, obj, form, change):
+ if obj.start_date and obj.expiry_date and obj.expiry_date < obj.start_date:
+ self.message_user(
+ request,
+ "⚠️ Expiry date cannot be earlier than start date.",
+ level="warning",
+ )
+ elif obj.expiry_date and obj.expiry_date < timezone.localdate():
+ self.message_user(
+ request, "⚠️ Expiry date is already in the past.", level="warning"
+ )
+ super().save_model(request, obj, form, change)
+
+
+@admin.register(Credential, site=admin_site)
+class CredentialAdmin(admin.ModelAdmin):
+ list_display = (
+ "service_display",
+ "username_display",
+ "url_display",
+ "notes_display",
+ )
+ list_filter = ("service__service_type", "service__client")
+ search_fields = ("username", "service__name", "notes")
+ ordering = ("service",)
+
+ fieldsets = (
+ (
+ _("Credential Info"),
+ {
+ "fields": ("service", "url", "username", "password", "notes"),
+ },
+ ),
+ )
+
+ # --- DISPLAY HELPERS ---
+ def service_display(self, obj):
+ return format_html(
+ "{}
{}",
+ obj.service.name,
+ obj.service.client.name,
+ )
+
+ service_display.short_description = "Service"
+
+ def username_display(self, obj):
+ return obj.username or format_html("—")
+
+ username_display.short_description = "Username"
+
+ def url_display(self, obj):
+ if obj.url:
+ return format_html("{}", obj.url, obj.url)
+ return format_html("—")
+
+ url_display.short_description = "URL"
+
+ def notes_display(self, obj):
+ return (
+ (obj.notes[:60] + "...")
+ if obj.notes and len(obj.notes) > 60
+ else (obj.notes or "—")
+ )
+
+ notes_display.short_description = "Notes"
+
+ def get_queryset(self, request):
+ qs = super().get_queryset(request)
+ return qs.select_related("service", "service__client")
diff --git a/src/asgi.py b/src/asgi.py
index aa7df17..bbd3bd0 100644
--- a/src/asgi.py
+++ b/src/asgi.py
@@ -1,11 +1,15 @@
"""generated with djinit"""
import os
+
import environ
from django.core.asgi import get_asgi_application
env = environ.Env()
environ.Env.read_env()
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE", default="core.settings.production"))
+os.environ.setdefault(
+ "DJANGO_SETTINGS_MODULE",
+ env("DJANGO_SETTINGS_MODULE", default="core.settings.production"),
+)
application = get_asgi_application()
diff --git a/src/models/__init__.py b/src/models/__init__.py
index 980c8e5..6cb4fd6 100644
--- a/src/models/__init__.py
+++ b/src/models/__init__.py
@@ -1,5 +1,3 @@
"""generated with djinit"""
-from django.db import models
-
# Create your models here.
diff --git a/src/models/base.py b/src/models/base.py
index e69de29..2b990f8 100644
--- a/src/models/base.py
+++ b/src/models/base.py
@@ -0,0 +1,9 @@
+from django.db import models
+
+
+class TimeStampedModel(models.Model):
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ abstract = True
diff --git a/src/models/clients.py b/src/models/clients.py
index e69de29..24a6d81 100644
--- a/src/models/clients.py
+++ b/src/models/clients.py
@@ -0,0 +1,61 @@
+from django.core.exceptions import ValidationError
+from django.db import models
+from phonenumber_field.modelfields import PhoneNumberField
+
+from src.models.base import TimeStampedModel
+
+
+class Client(TimeStampedModel):
+ name = models.CharField(max_length=255, db_index=True)
+ short_code = models.CharField(max_length=6, unique=True, blank=True)
+ email = models.EmailField()
+ phone = PhoneNumberField()
+ address = models.TextField()
+ company_name = models.CharField(max_length=255, blank=True)
+
+ class Meta:
+ ordering = ["name"]
+ indexes = [
+ models.Index(fields=["name"]),
+ models.Index(fields=["short_code"]),
+ ]
+
+ def clean(self):
+ # Optional validation for short_code length
+ if self.short_code and len(self.short_code) > 6:
+ raise ValidationError("Short code cannot be more than 6 characters.")
+
+ def generate_short_code(self):
+ """
+ Generates a unique 6-character code:
+ First 3 letters of name + incremental counter.
+ Example: SAN001, SAN002, etc.
+ Ensures uniqueness even if multiple clients share the same prefix.
+ """
+ base = self.name[:3].upper()
+
+ # Find existing codes with same prefix
+ existing = Client.objects.filter(short_code__startswith=base).values_list(
+ "short_code", flat=True
+ )
+
+ # Extract available next number
+ numbers = []
+ for code in existing:
+ try:
+ number = int(code[3:])
+ numbers.append(number)
+ except (ValueError, IndexError):
+ continue
+
+ next_num = max(numbers, default=0) + 1
+ return f"{base}{next_num:03d}"
+
+ def save(self, *args, **kwargs):
+ if not self.short_code:
+ self.short_code = self.generate_short_code()
+
+ super().save(*args, **kwargs)
+
+ def __str__(self):
+ return f"{self.name} ({self.short_code})"
diff --git a/src/models/finance.py b/src/models/finance.py
index e69de29..50cdb4d 100644
--- a/src/models/finance.py
+++ b/src/models/finance.py
@@ -0,0 +1,83 @@
+from django.db import models
+
+from src.models.base import TimeStampedModel
+from src.models.clients import Client
+from src.models.projects import Project
+from src.models.services import Service
+
+
+class Invoice(TimeStampedModel):
+ STATUS_CHOICES = [
+ ("DRAFT", "Draft"),
+ ("SENT", "Sent"),
+ ("PAID", "Paid"),
+ ("OVERDUE", "Overdue"),
+ ("CANCELLED", "Cancelled"),
+ ]
+ client = models.ForeignKey(
+ Client, on_delete=models.CASCADE, related_name="invoices"
+ )
+ project = models.ForeignKey(
+ Project, on_delete=models.CASCADE, related_name="invoices"
+ )
+ invoice_number = models.CharField(max_length=20, unique=True, editable=False)
+ amount = models.DecimalField(max_digits=10, decimal_places=2)
+ date_issued = models.DateField(auto_now_add=True)
+ due_date = models.DateField(auto_now=True)
+ status = models.CharField(max_length=10, choices=STATUS_CHOICES, default="DRAFT")
+
+ def save(self, *args, **kwargs):
+ if not self.id:
+ client_code = self.client.short_code # e.g., "NEX001"
+
+ # Find last invoice for this client
+ last_invoice = (
+ Invoice.objects.filter(invoice_number__startswith=f"{client_code}-")
+ .order_by("-invoice_number")
+ .first()
+ )
+
+ if last_invoice:
+ last_number = int(last_invoice.invoice_number.split("-")[1])
+ new_number = last_number + 1
+ else:
+ new_number = 1
+
+ # final format → NEX001-0001
+ self.invoice_number = f"{client_code}-{new_number:04d}"
+
+ super().save(*args, **kwargs)
+
+ def __str__(self):
+ return f"Invoice {self.invoice_number}"
+
+
+class Expense(models.Model):
+ service = models.ForeignKey(
+ Service,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="expenses",
+ )
+ description = models.CharField(max_length=255)
+ amount = models.DecimalField(max_digits=10, decimal_places=2)
+ date = models.DateField()
+ category = models.CharField(max_length=100, blank=True)
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ def __str__(self):
+ return f"{self.description} - {self.amount}"
+
+
+class Payment(models.Model):
+ invoice = models.ForeignKey(
+ Invoice, on_delete=models.CASCADE, related_name="payments"
+ )
+ amount = models.DecimalField(max_digits=10, decimal_places=2)
+ date = models.DateField()
+ method = models.CharField(max_length=50, blank=True)
+ transaction_id = models.CharField(max_length=100, blank=True)
+
+ def __str__(self):
+ return f"Payment of {self.amount} for {self.invoice}"
diff --git a/src/models/productivity.py b/src/models/productivity.py
index e69de29..e294fd0 100644
--- a/src/models/productivity.py
+++ b/src/models/productivity.py
@@ -0,0 +1,65 @@
+from django.core.exceptions import ValidationError
+from django.db import models
+
+from src.models.projects import Project, Task
+
+
+class Note(models.Model):
+ title = models.CharField(max_length=255, db_index=True)
+ content = models.TextField()
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+
+ def __str__(self):
+ return self.title[:50] # avoid super long titles
+
+
+class TimeEntry(models.Model):
+ project = models.ForeignKey(
+ Project,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="time_entries",
+ )
+ task = models.ForeignKey(
+ Task,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="time_entries",
+ )
+ description = models.CharField(max_length=255)
+ start_time = models.DateTimeField()
+ end_time = models.DateTimeField(null=True, blank=True)
+ duration = models.DurationField(null=True, blank=True, editable=False)
+
+ class Meta:
+ ordering = ["-start_time"]
+ indexes = [
+ models.Index(fields=["start_time"]),
+ models.Index(fields=["end_time"]),
+ models.Index(fields=["project"]),
+ models.Index(fields=["task"]),
+ ]
+
+ def clean(self):
+ if self.end_time and self.start_time and self.end_time < self.start_time:
+ raise ValidationError("End time cannot be earlier than start time.")
+
+ def save(self, *args, **kwargs):
+ if self.start_time and self.end_time:
+ self.duration = self.end_time - self.start_time
+ else:
+ self.duration = None
+
+ super().save(*args, **kwargs)
+
+ def __str__(self):
+ duration_str = (
+ str(self.duration).split(".")[0] if self.duration else "In Progress"
+ )
+ return f"{self.description} — {duration_str}"
diff --git a/src/models/projects.py b/src/models/projects.py
index e69de29..f007497 100644
--- a/src/models/projects.py
+++ b/src/models/projects.py
@@ -0,0 +1,93 @@
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.utils import timezone
+
+from src.models.base import TimeStampedModel
+from src.models.clients import Client
+
+
+class Project(TimeStampedModel):
+ STATUS_CHOICES = [
+ ("PLANNING", "Planning"),
+ ("IN_PROGRESS", "In Progress"),
+ ("ON_HOLD", "On Hold"),
+ ("COMPLETED", "Completed"),
+ ("CANCELLED", "Cancelled"),
+ ]
+
+ client = models.ForeignKey(
+ Client, on_delete=models.CASCADE, related_name="projects"
+ )
+ name = models.CharField(max_length=255, db_index=True)
+ description = models.TextField()
+ start_date = models.DateField(null=True, blank=True)
+ deadline = models.DateField(null=True, blank=True)
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="PLANNING")
+ budget = models.DecimalField(max_digits=10, decimal_places=2, null=True, blank=True)
+
+ class Meta:
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["status"]),
+ models.Index(fields=["client"]),
+ ]
+
+ def clean(self):
+ if self.start_date and self.deadline and self.deadline < self.start_date:
+ raise ValidationError("Deadline cannot be earlier than start date.")
+
+ def __str__(self):
+ return f"{self.name} ({self.get_status_display()})"
+
+
+class Milestone(TimeStampedModel):
+ project = models.ForeignKey(
+ Project, on_delete=models.CASCADE, related_name="milestones"
+ )
+ title = models.CharField(max_length=255)
+ due_date = models.DateField(null=True, blank=True)
+ is_completed = models.BooleanField(default=False)
+
+ class Meta:
+ ordering = ["due_date"]
+ indexes = [
+ models.Index(fields=["project"]),
+ models.Index(fields=["due_date"]),
+ ]
+
+ def __str__(self):
+ status = "✓" if self.is_completed else "✗"
+ return f"{self.title} [{status}]"
+
+
+class Task(TimeStampedModel):
+ STATUS_CHOICES = [
+ ("TODO", "To Do"),
+ ("IN_PROGRESS", "In Progress"),
+ ("REVIEW", "Review"),
+ ("DONE", "Done"),
+ ]
+
+ project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="tasks")
+ title = models.CharField(max_length=255, db_index=True)
+ description = models.TextField(blank=True)
+ status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="TODO")
+ due_date = models.DateField(null=True, blank=True)
+
+ class Meta:
+ ordering = ["status", "due_date"]
+ indexes = [
+ models.Index(fields=["project"]),
+ models.Index(fields=["status"]),
+ models.Index(fields=["due_date"]),
+ ]
+
+ def clean(self):
+ today = timezone.localdate()
+
+ # Ensure due_date is not in the past
+ if self.due_date and self.due_date < today:
+ raise ValidationError("Due date cannot be in the past.")
+
+ def __str__(self):
+ return f"{self.title} — {self.get_status_display()}"
diff --git a/src/models/services.py b/src/models/services.py
index e69de29..9e4013a 100644
--- a/src/models/services.py
+++ b/src/models/services.py
@@ -0,0 +1,72 @@
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.utils import timezone
+
+from src.models.base import TimeStampedModel
+from src.models.clients import Client
+
+
+class Service(TimeStampedModel):
+ TYPE_CHOICES = [
+ ("DOMAIN", "Domain"),
+ ("HOSTING", "Hosting"),
+ ("VPS", "VPS"),
+ ("SSL", "SSL"),
+ ("OTHER", "Other"),
+ ]
+
+ client = models.ForeignKey(
+ Client, on_delete=models.CASCADE, related_name="services"
+ )
+ service_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
+ name = models.CharField(
+ max_length=255, help_text="e.g. example.com or VPS - 4GB", db_index=True
+ )
+ provider = models.CharField(max_length=255, blank=True)
+ cost = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
+ renewal_price = models.DecimalField(max_digits=10, decimal_places=2, default=0.00)
+ start_date = models.DateField(null=True, blank=True)
+ expiry_date = models.DateField(null=True, blank=True)
+ auto_renew = models.BooleanField(default=False)
+
+ class Meta:
+ ordering = ["-created_at"]
+ indexes = [
+ models.Index(fields=["service_type"]),
+ models.Index(fields=["client"]),
+ models.Index(fields=["expiry_date"]),
+ ]
+
+ def clean(self):
+ # ensure expiry_date is not earlier than start_date
+ if self.start_date and self.expiry_date and self.expiry_date < self.start_date:
+ raise ValidationError("Expiry date cannot be earlier than start date.")
+
+ # optional: prevent expiry date in the past for new services
+ today = timezone.localdate()
+ if self.expiry_date and self.expiry_date < today:
+ raise ValidationError("Expiry date cannot be in the past.")
+
+ def __str__(self):
+ return f"{self.get_service_type_display()} – {self.name}"
+
+
+class Credential(models.Model):
+ service = models.ForeignKey(
+ Service, on_delete=models.CASCADE, related_name="credentials"
+ )
+ url = models.URLField(blank=True)
+ username = models.CharField(max_length=255, blank=True)
+ password = models.CharField(max_length=255, blank=True)
+ notes = models.TextField(blank=True)
+
+ class Meta:
+ ordering = ["service"]
+ indexes = [
+ models.Index(fields=["service"]),
+ ]
+
+ def __str__(self):
+ # Avoid showing passwords / sensitive info
+ cred_name = self.username or "No Username"
+ return f"Credential ({cred_name}) for {self.service.name}"
diff --git a/src/settings/base.py b/src/settings/base.py
index d94f298..d3550c3 100644
--- a/src/settings/base.py
+++ b/src/settings/base.py
@@ -22,6 +22,7 @@ THIRD_PARTY_APPS = [
"django_components",
"django_htmx",
"nepali_datetime_field",
+ "phonenumber_field",
]
USER_DEFINED_APPS = [
@@ -94,7 +95,7 @@ AUTH_PASSWORD_VALIDATORS = [
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = "en-us"
-TIME_ZONE = "UTC"
+TIME_ZONE = "Asia/Kathmandu"
USE_I18N = True
USE_TZ = True
diff --git a/src/settings/development.py b/src/settings/development.py
index 706621b..02e0e8e 100644
--- a/src/settings/development.py
+++ b/src/settings/development.py
@@ -1,6 +1,6 @@
"""generated with djinit"""
-from .base import *
+from .base import * # noqa: F403, F405
SECRET_KEY = "RKOHQN9iLI(J50m4KW=ge(t#69PI&BTI!yp*iq&0wtcW350dD="
@@ -15,7 +15,7 @@ ALLOWED_HOSTS = ["*"]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
- "NAME": BASE_DIR / "db.sqlite3",
+ "NAME": BASE_DIR / "db.sqlite3", # noqa: F405
"OPTIONS": {
"transaction_mode": "IMMEDIATE",
"timeout": 5, # seconds
diff --git a/src/settings/production.py b/src/settings/production.py
index e2e1e64..0e26752 100644
--- a/src/settings/production.py
+++ b/src/settings/production.py
@@ -1,14 +1,14 @@
"""generated with djinit"""
-from .base import *
+from .base import * # noqa: F403, F405
DEBUG = False
-SECRET_KEY = env("SECRET_KEY")
-ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[])
+SECRET_KEY = env("SECRET_KEY") # noqa: F405
+ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[]) # noqa: F405
# Database
# Using dj_database_url (recommended for production)
-DATABASES = {"default": env.db("DATABASE_URL")}
+DATABASES = {"default": env.db("DATABASE_URL")} # noqa: F405
# CORS settings for production
# Filter out empty strings from FRONTEND_URL
@@ -18,13 +18,13 @@ CORS_ALLOW_PRIVATE_NETWORK = False
# Email settings
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
-EMAIL_HOST = env("EMAIL_HOST")
-EMAIL_PORT = env.int("EMAIL_PORT", default=587)
-EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=True)
-EMAIL_HOST_USER = env("EMAIL_HOST_USER")
-EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
-DEFAULT_FROM_EMAIL = env("EMAIL_HOST_USER")
-SERVER_EMAIL = env("EMAIL_HOST_USER")
+EMAIL_HOST = env("EMAIL_HOST") # noqa: F405
+EMAIL_PORT = env.int("EMAIL_PORT", default=587) # noqa: F405
+EMAIL_USE_TLS = env.bool("EMAIL_USE_TLS", default=True) # noqa: F405
+EMAIL_HOST_USER = env("EMAIL_HOST_USER") # noqa: F405
+EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD") # noqa: F405
+DEFAULT_FROM_EMAIL = env("EMAIL_HOST_USER") # noqa: F405
+SERVER_EMAIL = env("EMAIL_HOST_USER") # noqa: F405
# Security settings for production
SECURE_SSL_REDIRECT = True
diff --git a/src/urls.py b/src/urls.py
index d1f52f9..2b6d68d 100644
--- a/src/urls.py
+++ b/src/urls.py
@@ -1,30 +1,9 @@
"""generated with djinit"""
-from django.contrib import admin
-from django.urls import path, include
-from django.conf import settings
-from drf_spectacular.views import (
- SpectacularAPIView,
- SpectacularSwaggerView,
-)
-from rest_framework_simplejwt.views import (
- TokenObtainPairView,
- TokenRefreshView,
- TokenBlacklistView,
-)
+from django.urls import path
+
+from src.admin.base import admin_site
urlpatterns = [
- path("admin/", admin.site.urls),
- # JWT tokens
- path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
- path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
- path("token/blacklist/", TokenBlacklistView.as_view(), name="token_blacklist"),
-# path("api/", include("src.api.urls")),
+ path("admin/", admin_site.urls),
]
-
-# Don't show schema in production
-if settings.DEBUG:
- urlpatterns += [
- path("schema/", SpectacularAPIView.as_view(), name="schema"),
- path("docs/", SpectacularSwaggerView.as_view(url_name="schema")),
- ]
diff --git a/src/wsgi.py b/src/wsgi.py
index 22bf67e..08cbd55 100644
--- a/src/wsgi.py
+++ b/src/wsgi.py
@@ -1,12 +1,16 @@
"""generated with djinit"""
import os
+
import environ
from django.core.wsgi import get_wsgi_application
env = environ.Env()
environ.Env.read_env()
-os.environ.setdefault("DJANGO_SETTINGS_MODULE", env("DJANGO_SETTINGS_MODULE", default="core.settings.production"))
+os.environ.setdefault(
+ "DJANGO_SETTINGS_MODULE",
+ env("DJANGO_SETTINGS_MODULE", default="core.settings.production"),
+)
application = get_wsgi_application()
diff --git a/uv.lock b/uv.lock
index 793a58b..955b8e6 100644
--- a/uv.lock
+++ b/uv.lock
@@ -22,12 +22,18 @@ dependencies = [
{ name = "django-htmx" },
{ name = "django-jazzmin" },
{ name = "django-nepali-datetime-field" },
+ { name = "django-phonenumber-field", extra = ["phonenumbers"] },
{ name = "gunicorn" },
{ name = "mysqlclient" },
{ name = "psycopg2-binary" },
{ name = "whitenoise" },
]
+[package.dev-dependencies]
+dev = [
+ { name = "ruff" },
+]
+
[package.metadata]
requires-dist = [
{ name = "django" },
@@ -36,12 +42,16 @@ requires-dist = [
{ name = "django-htmx", specifier = ">=1.27.0" },
{ name = "django-jazzmin" },
{ name = "django-nepali-datetime-field", specifier = ">=0.8.0" },
+ { name = "django-phonenumber-field", extras = ["phonenumbers"], specifier = ">=8.4.0" },
{ name = "gunicorn" },
{ name = "mysqlclient" },
{ name = "psycopg2-binary" },
{ name = "whitenoise" },
]
+[package.metadata.requires-dev]
+dev = [{ name = "ruff", specifier = ">=0.14.8" }]
+
[[package]]
name = "django"
version = "6.0"
@@ -117,6 +127,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/15/fc/62ff130c5414a9030410673356d21e376e59cf045ecdabdef40aaf06f833/django_nepali_datetime_field-0.8.0-py3-none-any.whl", hash = "sha256:34eaab138856f69374db7af7274bafd7621224937feaf84a610e9d9872b52b37", size = 7696, upload-time = "2025-03-09T04:19:48.252Z" },
]
+[[package]]
+name = "django-phonenumber-field"
+version = "8.4.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "django" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/87/bf/8aa60c9834773b955dff1ddea842e361e8daaf6b0945d5bfc29fc66d53ab/django_phonenumber_field-8.4.0.tar.gz", hash = "sha256:2b83e843dac35eec6a69880a166487235b737a71a1e38c9a52e5ad67d6996083", size = 45512, upload-time = "2025-11-24T15:09:51.904Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/a3/f6b85a9246e22cf719752ad83f5e95aa9ba12419b2f9eff70f20d30e55df/django_phonenumber_field-8.4.0-py3-none-any.whl", hash = "sha256:7a1cb3a6456edb54d879f11ffa0acb227ded08c93b587035d0f28093f0e46511", size = 69528, upload-time = "2025-11-24T15:09:45.479Z" },
+]
+
+[package.optional-dependencies]
+phonenumbers = [
+ { name = "phonenumbers" },
+]
+
[[package]]
name = "djc-core-html-parser"
version = "1.0.3"
@@ -191,6 +218,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
+[[package]]
+name = "phonenumbers"
+version = "9.0.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1b/2a/d6e7fecd1bcd37e65fbb9386e492082bd84e196c9cab14e39166b35cea4b/phonenumbers-9.0.20.tar.gz", hash = "sha256:849788eec8e5a9737a99c8b906d18a62d9fced6497ba7033784b6a7e4c89bb2d", size = 2298276, upload-time = "2025-12-05T12:03:51.072Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/90/b0/e3fd0ae68bc51593ef6f77a44901d1fa14c1b230cdce848b50ed10881ca3/phonenumbers-9.0.20-py2.py3-none-any.whl", hash = "sha256:03bf5dd14891891284ba96f803d0e5e7e11b9306a0ec4fdf25756ada39eacb86", size = 2584219, upload-time = "2025-12-05T12:03:48.388Z" },
+]
+
[[package]]
name = "psycopg2-binary"
version = "2.9.11"
@@ -221,6 +257,32 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" },
]
+[[package]]
+name = "ruff"
+version = "0.14.8"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/ed/d9/f7a0c4b3a2bf2556cd5d99b05372c29980249ef71e8e32669ba77428c82c/ruff-0.14.8.tar.gz", hash = "sha256:774ed0dd87d6ce925e3b8496feb3a00ac564bea52b9feb551ecd17e0a23d1eed", size = 5765385, upload-time = "2025-12-04T15:06:17.669Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/48/b8/9537b52010134b1d2b72870cc3f92d5fb759394094741b09ceccae183fbe/ruff-0.14.8-py3-none-linux_armv6l.whl", hash = "sha256:ec071e9c82eca417f6111fd39f7043acb53cd3fde9b1f95bbed745962e345afb", size = 13441540, upload-time = "2025-12-04T15:06:14.896Z" },
+ { url = "https://files.pythonhosted.org/packages/24/00/99031684efb025829713682012b6dd37279b1f695ed1b01725f85fd94b38/ruff-0.14.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8cdb162a7159f4ca36ce980a18c43d8f036966e7f73f866ac8f493b75e0c27e9", size = 13669384, upload-time = "2025-12-04T15:06:51.809Z" },
+ { url = "https://files.pythonhosted.org/packages/72/64/3eb5949169fc19c50c04f28ece2c189d3b6edd57e5b533649dae6ca484fe/ruff-0.14.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e2fcbefe91f9fad0916850edf0854530c15bd1926b6b779de47e9ab619ea38f", size = 12806917, upload-time = "2025-12-04T15:06:08.925Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/08/5250babb0b1b11910f470370ec0cbc67470231f7cdc033cee57d4976f941/ruff-0.14.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9d70721066a296f45786ec31916dc287b44040f553da21564de0ab4d45a869b", size = 13256112, upload-time = "2025-12-04T15:06:23.498Z" },
+ { url = "https://files.pythonhosted.org/packages/78/4c/6c588e97a8e8c2d4b522c31a579e1df2b4d003eddfbe23d1f262b1a431ff/ruff-0.14.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c87e09b3cd9d126fc67a9ecd3b5b1d3ded2b9c7fce3f16e315346b9d05cfb52", size = 13227559, upload-time = "2025-12-04T15:06:33.432Z" },
+ { url = "https://files.pythonhosted.org/packages/23/ce/5f78cea13eda8eceac71b5f6fa6e9223df9b87bb2c1891c166d1f0dce9f1/ruff-0.14.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1d62cb310c4fbcb9ee4ac023fe17f984ae1e12b8a4a02e3d21489f9a2a5f730c", size = 13896379, upload-time = "2025-12-04T15:06:02.687Z" },
+ { url = "https://files.pythonhosted.org/packages/cf/79/13de4517c4dadce9218a20035b21212a4c180e009507731f0d3b3f5df85a/ruff-0.14.8-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1af35c2d62633d4da0521178e8a2641c636d2a7153da0bac1b30cfd4ccd91344", size = 15372786, upload-time = "2025-12-04T15:06:29.828Z" },
+ { url = "https://files.pythonhosted.org/packages/00/06/33df72b3bb42be8a1c3815fd4fae83fa2945fc725a25d87ba3e42d1cc108/ruff-0.14.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:25add4575ffecc53d60eed3f24b1e934493631b48ebbc6ebaf9d8517924aca4b", size = 14990029, upload-time = "2025-12-04T15:06:36.812Z" },
+ { url = "https://files.pythonhosted.org/packages/64/61/0f34927bd90925880394de0e081ce1afab66d7b3525336f5771dcf0cb46c/ruff-0.14.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c943d847b7f02f7db4201a0600ea7d244d8a404fbb639b439e987edcf2baf9a", size = 14407037, upload-time = "2025-12-04T15:06:39.979Z" },
+ { url = "https://files.pythonhosted.org/packages/96/bc/058fe0aefc0fbf0d19614cb6d1a3e2c048f7dc77ca64957f33b12cfdc5ef/ruff-0.14.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb6e8bf7b4f627548daa1b69283dac5a296bfe9ce856703b03130732e20ddfe2", size = 14102390, upload-time = "2025-12-04T15:06:46.372Z" },
+ { url = "https://files.pythonhosted.org/packages/af/a4/e4f77b02b804546f4c17e8b37a524c27012dd6ff05855d2243b49a7d3cb9/ruff-0.14.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:7aaf2974f378e6b01d1e257c6948207aec6a9b5ba53fab23d0182efb887a0e4a", size = 14230793, upload-time = "2025-12-04T15:06:20.497Z" },
+ { url = "https://files.pythonhosted.org/packages/3f/52/bb8c02373f79552e8d087cedaffad76b8892033d2876c2498a2582f09dcf/ruff-0.14.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e5758ca513c43ad8a4ef13f0f081f80f08008f410790f3611a21a92421ab045b", size = 13160039, upload-time = "2025-12-04T15:06:49.06Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/ad/b69d6962e477842e25c0b11622548df746290cc6d76f9e0f4ed7456c2c31/ruff-0.14.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f74f7ba163b6e85a8d81a590363bf71618847e5078d90827749bfda1d88c9cdf", size = 13205158, upload-time = "2025-12-04T15:06:54.574Z" },
+ { url = "https://files.pythonhosted.org/packages/06/63/54f23da1315c0b3dfc1bc03fbc34e10378918a20c0b0f086418734e57e74/ruff-0.14.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:eed28f6fafcc9591994c42254f5a5c5ca40e69a30721d2ab18bb0bb3baac3ab6", size = 13469550, upload-time = "2025-12-04T15:05:59.209Z" },
+ { url = "https://files.pythonhosted.org/packages/70/7d/a4d7b1961e4903bc37fffb7ddcfaa7beb250f67d97cfd1ee1d5cddb1ec90/ruff-0.14.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:21d48fa744c9d1cb8d71eb0a740c4dd02751a5de9db9a730a8ef75ca34cf138e", size = 14211332, upload-time = "2025-12-04T15:06:06.027Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/93/2a5063341fa17054e5c86582136e9895db773e3c2ffb770dde50a09f35f0/ruff-0.14.8-py3-none-win32.whl", hash = "sha256:15f04cb45c051159baebb0f0037f404f1dc2f15a927418f29730f411a79bc4e7", size = 13151890, upload-time = "2025-12-04T15:06:11.668Z" },
+ { url = "https://files.pythonhosted.org/packages/02/1c/65c61a0859c0add13a3e1cbb6024b42de587456a43006ca2d4fd3d1618fe/ruff-0.14.8-py3-none-win_amd64.whl", hash = "sha256:9eeb0b24242b5bbff3011409a739929f497f3fb5fe3b5698aba5e77e8c833097", size = 14537826, upload-time = "2025-12-04T15:06:26.409Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/63/8b41cea3afd7f58eb64ac9251668ee0073789a3bc9ac6f816c8c6fef986d/ruff-0.14.8-py3-none-win_arm64.whl", hash = "sha256:965a582c93c63fe715fd3e3f8aa37c4b776777203d8e1d8aa3cc0c14424a4b99", size = 13634522, upload-time = "2025-12-04T15:06:43.212Z" },
+]
+
[[package]]
name = "sqlparse"
version = "0.5.4"