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:
1 parent
27ca456212
commit
8b50ff5412
24 files changed
+1483
-49
No files matched your search
@@ -1,8 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
"""generated with djinit"""
|
||||
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
@@ -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
@@ -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]
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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)."
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
@@ -1,5 +1,3 @@
|
||||
"""generated with djinit"""
|
||||
|
||||
from django.db import models
|
||||
|
||||
# Create your models here.
|
||||
@@ -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
|
||||
@@ -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})"
|
||||
@@ -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}"
|
||||
@@ -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}"
|
||||
@@ -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()}"
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user