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"