This commit refactors the models in the 'models' app, specifically 'clients' and 'services' modules. It includes the PhoneNumberField in the Client model and adds a new Credential model to the services module. The Service model has been modified to include new fields and relationships.

This commit is contained in:
sankalp committed 2025-12-09 13:35:09 +05:45
1 parent 27ca456212
commit 8b50ff5412
24 files changed
+1483 -49

No files matched your search

+1 -2
View File
@@ -1,8 +1,7 @@
#!/usr/bin/env python
"""generated with djinit"""
"""Django's command-line utility for administrative tasks."""
import os
import sys
+6
View File
@@ -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",
]
+5 -1
View File
@@ -12,4 +12,8 @@ django-cors-headers
whitenoise
mysqlclient
gunicorn
dj-database-url
dj-database-url
django-components
django-htmx
nepali-datetime-field
django-phonenumber-field[phonenumbers]
+8 -3
View File
@@ -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,
)
+137
View File
@@ -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)
+73
View File
@@ -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(
"<strong>{}</strong><br><small style='color: #888;'>{}</small>",
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)
+196
View File
@@ -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 <b>invoice number</b> 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(
"<strong>{}</strong><br><small>{}</small>",
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("<b>${:,.2f}</b>", 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(
"<span style='color: white; background: {}; padding: 3px 8px; border-radius: 6px; font-size: 12px;'>{}</span>",
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("<b>${:,.2f}</b>", 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(
"<strong>{}</strong><br><small>{}</small>",
obj.invoice.invoice_number,
obj.invoice.client.name,
)
invoice_display.short_description = "Invoice"
def amount_display(self, obj):
return format_html("<b>${:,.2f}</b>", obj.amount)
amount_display.short_description = "Amount"
+133
View File
@@ -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("<strong>{}</strong>", 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("<strong>{}</strong>", 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("<span style='color: #999;'>In Progress</span>")
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(
"<span style='color: {}; font-weight: 600;'>{:.2f} hrs</span>",
color,
hours,
)
return format_html("<span style='color: #888;'>—</span>")
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)."
)
+233
View File
@@ -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("<strong>{}</strong>", 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(
"<span style='background:{}; color:white; padding:3px 8px; border-radius:6px; font-size:12px;'>{}</span>",
color,
obj.get_status_display(),
)
status_badge.short_description = "Status"
def budget_display(self, obj):
return format_html("<b>${:,.2f}</b>", 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("<strong>{}</strong>", 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(
"<span style='background:{}; color:white; padding:2px 7px; border-radius:5px; font-size:12px;'>{}</span>",
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("<strong>{}</strong>", 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("<span style='color:#999;'>No due date</span>")
elif obj.due_date < timezone.localdate():
return format_html("<span style='color:#dc3545;'>{}</span>", obj.due_date)
elif obj.due_date == timezone.localdate():
return format_html("<span style='color:#f39c12;'>Today</span>")
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(
"<span style='background:{}; color:white; padding:3px 8px; border-radius:6px; font-size:12px;'>{}</span>",
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)
+217
View File
@@ -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("<strong>{}</strong>", 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(
"<span style='background:{}; color:white; padding:3px 8px; border-radius:6px; font-size:12px;'>{}</span>",
color,
obj.get_service_type_display(),
)
type_badge.short_description = "Type"
def cost_display(self, obj):
return format_html("<b>${:,.2f}</b>", obj.cost)
cost_display.short_description = "Cost"
def renewal_price_display(self, obj):
return format_html("<b>${:,.2f}</b>", 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("<span style='color:#888;'>—</span>")
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(
"<span style='color:{}; font-weight:600;'>{}</span><br><small>{}</small>",
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"<span style='color:{color}; font-size:14px;'>{icon}</span>"
)
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(
"<strong>{}</strong><br><small>{}</small>",
obj.service.name,
obj.service.client.name,
)
service_display.short_description = "Service"
def username_display(self, obj):
return obj.username or format_html("<span style='color:#888;'>—</span>")
username_display.short_description = "Username"
def url_display(self, obj):
if obj.url:
return format_html("<a href='{}' target='_blank'>{}</a>", obj.url, obj.url)
return format_html("<span style='color:#888;'>—</span>")
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")
+5 -1
View File
@@ -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()
-2
View File
@@ -1,5 +1,3 @@
"""generated with djinit"""
from django.db import models
# Create your models here.
+9
View File
@@ -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
+61
View File
@@ -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})"
+83
View File
@@ -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}"
+65
View File
@@ -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}"
+93
View File
@@ -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()}"
+72
View File
@@ -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}"
+2 -1
View File
@@ -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
+2 -2
View File
@@ -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
+11 -11
View File
@@ -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
+4 -25
View File
@@ -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")),
]
+5 -1
View File
@@ -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()
Generated
+62
View File
@@ -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"