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,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")
|
||||
Reference in new issue
Block a user