Author SHA1 Message Date
westfarn faca7b1084 Dockerize Django app with dev/beta/prod env config and uv.
CI / test (pull_request) Successful in 7s
Replace hardcoded settings with environment-driven config, add Docker
compose for local and production deploys, migrate from pip to uv, and
split Gitea workflows so PRs run tests only while master pushes deploy.
2026-07-03 06:15:43 -05:00
19 changed files with 59 additions and 750 deletions
+6 -12
View File
@@ -2,9 +2,7 @@ name: Deploy Company Site
# Deploy pipeline runs only on pushes to master (never on pull requests).
on:
workflow_run:
workflows: [Unit Tests]
types: [completed]
push:
branches: [master]
jobs:
@@ -51,15 +49,11 @@ jobs:
docker compose down
deploy:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted
needs: [test, docker]
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
steps:
- name: Deploy company_site prod
run: |
"${SERVER_INFRA_ROOT}/scripts/deploy.sh" \
--app company_site \
--env prod \
--ref "${{ gitea.event.workflow_run.head_sha }}"
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to live site
run: bash scripts/deploy.sh "${{ gitea.workspace }}"
-30
View File
@@ -1,30 +0,0 @@
name: Unit Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
run: |
cd company_site
uv run python manage.py test
+1 -12
View File
@@ -1,6 +1,5 @@
"""Shared Django settings for all environments."""
import json
import os
from pathlib import Path
from urllib.parse import urlparse
@@ -23,15 +22,6 @@ def env_list(key: str, default: str = "") -> list[str]:
value = os.environ.get(key, default)
if not value:
return []
value = value.strip()
# Accept a JSON array (e.g. '["a","b"]') as well as a comma-separated list.
if value.startswith("["):
try:
parsed = json.loads(value)
except ValueError:
parsed = None
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()]
return [item.strip() for item in value.split(",") if item.strip()]
@@ -123,7 +113,6 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages",
"public.context_processors.tianji_tracking",
"public.context_processors.webmcp_context",
"public.context_processors.financial_access",
],
},
},
@@ -157,7 +146,7 @@ STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"staticfiles": {
"BACKEND": "company_site.storage.TolerantManifestStaticFilesStorage",
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
-32
View File
@@ -1,32 +0,0 @@
"""Custom static files storage.
WhiteNoise's manifest storage post-processes JS/CSS during ``collectstatic`` and
strictly resolves every referenced file, including ``sourceMappingURL`` comments
in vendored bundles. Some third-party assets reference ``.map`` files that are
not shipped, which makes ``collectstatic`` fail hard.
``TolerantManifestStaticFilesStorage`` leaves such unresolved references
untouched instead of raising, so a missing source map can't break the build.
"""
from whitenoise.storage import CompressedManifestStaticFilesStorage
class TolerantManifestStaticFilesStorage(CompressedManifestStaticFilesStorage):
# Don't 500 at runtime when a {% static %} reference isn't in the manifest;
# fall back to the plain name (mirrors non-manifest storage behaviour).
manifest_strict = False
def _stored_name(self, name, hashed_files):
"""Tolerate missing references during collectstatic post-processing."""
try:
return super()._stored_name(name, hashed_files)
except ValueError:
return name
def stored_name(self, name):
"""Tolerate missing manifest entries at request time."""
try:
return super().stored_name(name)
except ValueError:
return name
+2 -6
View File
@@ -1,5 +1,6 @@
from django.contrib import admin
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell
# Register your models here.
class ContractAdmin(admin.ModelAdmin):
pass
@@ -7,10 +8,6 @@ class ContractAdmin(admin.ModelAdmin):
class EmployeeAdmin(admin.ModelAdmin):
pass
class UserProfileAdmin(admin.ModelAdmin):
list_display = ("user", "user_type")
list_filter = ("user_type",)
class ChargeNumberAdmin(admin.ModelAdmin):
pass
@@ -22,7 +19,6 @@ class TimeCardCellAdmin(admin.ModelAdmin):
admin.site.register(Contract, ContractAdmin)
admin.site.register(Employee, EmployeeAdmin)
admin.site.register(UserProfile, UserProfileAdmin)
admin.site.register(ChargeNumber, ChargeNumberAdmin)
admin.site.register(TimeCard, TimeCardAdmin)
admin.site.register(TimeCardCell, TimeCardCellAdmin)
+1 -12
View File
@@ -1,8 +1,7 @@
import datetime
from django import forms
from django.contrib.auth.models import User
from django.forms import ModelForm
from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel, UserProfile, set_user_type
from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel
class NewEmployeeForm(ModelForm):
first_name = forms.CharField(max_length=30, required=False, label="First Name")
@@ -38,7 +37,6 @@ class NewEmployeeForm(ModelForm):
employee.workAddress = address
if commit:
employee.save()
set_user_type(employee.user, UserProfile.UserType.EMPLOYEE)
return employee
class EmployeeForm(ModelForm):
@@ -46,15 +44,6 @@ class EmployeeForm(ModelForm):
model = Employee
fields = ["user", "manager", "primaryAddress", "workAddress", "phoneNumber", "slary"]
class UserProfileForm(ModelForm):
class Meta:
model = UserProfile
fields = ["user_type"]
class AdminUserTypeForm(forms.Form):
user = forms.ModelChoiceField(queryset=User.objects.order_by("username"))
user_type = forms.ChoiceField(choices=UserProfile.UserType.choices)
class ContractForm(ModelForm):
class Meta:
model = Contract
@@ -1,79 +0,0 @@
# Generated manually for issue #14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def migrate_user_profiles(apps, schema_editor):
User = apps.get_model("auth", "User")
UserProfile = apps.get_model("financial", "UserProfile")
Employee = apps.get_model("financial", "Employee")
TimeCardCell = apps.get_model("financial", "TimeCardCell")
employee_user_ids = set(
TimeCardCell.objects.values_list("timeCard__employee__user_id", flat=True)
)
for user in User.objects.all():
if user.id in employee_user_ids:
UserProfile.objects.update_or_create(
user_id=user.id,
defaults={"user_type": "employee"},
)
else:
UserProfile.objects.update_or_create(
user_id=user.id,
defaults={"user_type": "client"},
)
Employee.objects.filter(user_id=user.id).delete()
def reverse_migrate_user_profiles(apps, schema_editor):
User = apps.get_model("auth", "User")
Employee = apps.get_model("financial", "Employee")
for user in User.objects.all():
Employee.objects.get_or_create(user_id=user.id)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("financial", "0014_chargenumber_name"),
]
operations = [
migrations.CreateModel(
name="UserProfile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"user_type",
models.CharField(
choices=[("employee", "Employee"), ("client", "Client")],
default="client",
max_length=10,
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.RunPython(migrate_user_profiles, reverse_migrate_user_profiles),
]
-47
View File
@@ -243,29 +243,6 @@ class AddressModel(models.Model):
state = models.CharField(max_length=2)
zip_code = models.CharField(max_length=5)
class UserProfile(models.Model):
class UserType(models.TextChoices):
EMPLOYEE = "employee", "Employee"
CLIENT = "client", "Client"
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
user_type = models.CharField(
max_length=10,
choices=UserType.choices,
default=UserType.CLIENT,
)
def __str__(self):
return f"{self.user} ({self.get_user_type_display()})"
def is_employee(self):
return self.user_type == self.UserType.EMPLOYEE
def is_client(self):
return self.user_type == self.UserType.CLIENT
class Employee(IdMixin, TimeMixin):
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
user = models.OneToOneField(User, on_delete=models.CASCADE)
@@ -302,30 +279,6 @@ class TimeCardCell(IdMixin, TimeMixin):
charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True)
def set_user_type(user, user_type):
"""Set user type and sync the Employee record (mutually exclusive types)."""
user.__dict__.pop("profile", None)
profile, _ = UserProfile.objects.get_or_create(
user=user,
defaults={"user_type": user_type},
)
if profile.user_type == user_type:
if user_type == UserProfile.UserType.EMPLOYEE:
Employee.objects.get_or_create(user=user)
return profile
if user_type == UserProfile.UserType.CLIENT:
employee = Employee.objects.filter(user=user).first()
if employee and TimeCardCell.objects.filter(timeCard__employee=employee).exists():
raise ValueError("Cannot set Client: user has time log entries.")
profile.user_type = user_type
profile.save()
user.__dict__.pop("profile", None)
if user_type == UserProfile.UserType.EMPLOYEE:
Employee.objects.get_or_create(user=user)
else:
Employee.objects.filter(user=user).delete()
return profile
-76
View File
@@ -1,76 +0,0 @@
from functools import wraps
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
def get_user_profile(user):
if not user.is_authenticated:
return None
from .models import UserProfile
return UserProfile.objects.filter(user_id=user.pk).first()
def is_financial_admin(user):
return user.is_active and user.is_superuser
def is_employee_user(user):
profile = get_user_profile(user)
return bool(profile and profile.is_employee())
def is_client_user(user):
profile = get_user_profile(user)
return bool(profile and profile.is_client())
def has_financial_access(user):
return is_financial_admin(user) or is_employee_user(user) or is_client_user(user)
def can_write_financials(user):
return is_financial_admin(user) or is_employee_user(user)
def get_employees():
from .models import Employee, UserProfile
return Employee.objects.filter(
user__profile__user_type=UserProfile.UserType.EMPLOYEE
)
def get_user_employee(user):
from .models import Employee
if not is_employee_user(user):
return None
return Employee.objects.filter(user=user).first()
def financial_admin_required(view_func):
return user_passes_test(is_financial_admin)(view_func)
def financial_access_required(view_func):
@login_required
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
if has_financial_access(request.user):
return view_func(request, *args, **kwargs)
raise PermissionDenied
return _wrapped
def financial_write_required(view_func):
@login_required
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
if can_write_financials(request.user):
return view_func(request, *args, **kwargs)
raise PermissionDenied
return _wrapped
+4 -8
View File
@@ -2,14 +2,10 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import UserProfile
@receiver(post_save, sender=User)
def create_profile_for_user(sender, instance, created, **kwargs):
"""Auto-create a UserProfile (default Client) whenever a User is created."""
def create_employee_for_user(sender, instance, created, **kwargs):
"""Auto-create an Employee record whenever a User is created."""
from financial.models import Employee
if created:
UserProfile.objects.get_or_create(
user=instance,
defaults={"user_type": UserProfile.UserType.CLIENT},
)
Employee.objects.get_or_create(user=instance)
@@ -9,7 +9,6 @@
<h1 class="section-title">Dashboard</h1>
<div class="card-grid" style="margin-bottom: 3rem;">
{% if is_financial_admin %}
<a href="{% url 'contracts' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
<span class="card-title">View Contracts</span>
@@ -25,22 +24,15 @@
<span class="card-title">New Employee</span>
<p class="card-text">Add a new personnel member to your organization.</p>
</a>
<a href="{% url 'manage_users' %}" class="card">
<span class="card-title">Manage Users</span>
<p class="card-text">Set Employee or Client type for user accounts.</p>
</a>
{% endif %}
{% if can_write_financials %}
<a href="{% url 'Timekeeping' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping">
<span class="card-title">Log Time</span>
<p class="card-text">Record work hours against specific contracts.</p>
</a>
{% endif %}
<a href="{% url 'time_logs' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="time_logs">
<span class="card-title">{% if can_write_financials %}Manage{% else %}View{% endif %} Time Logs</span>
<p class="card-text">Review{% if can_write_financials %} and edit{% endif %} submitted time entries.</p>
<span class="card-title">Manage Time Logs</span>
<p class="card-text">Review and edit submitted time entries.</p>
</a>
<a href="{% url 'client_reports' %}" class="card">
<span class="card-title">Client Reports</span>
@@ -1,50 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Manage Users - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<h1 class="section-title" style="text-align: left;">Manage Users</h1>
{% if messages %}
{% for message in messages %}
<p style="margin-bottom: 1rem; color: {% if message.tags == 'error' %}#ff6666{% else %}var(--primary-color){% endif %};">
{{ message }}
</p>
{% endfor %}
{% endif %}
<div class="card" style="max-width: 600px; margin-bottom: 2rem;">
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Set User Type</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Update User Type</button>
</form>
</div>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Name</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.get_full_name|default:"—" }}</td>
<td>{{ u.profile.get_user_type_display|default:"Client" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -7,36 +7,13 @@
<div class="section">
<div class="container">
<h1 class="section-title" style="text-align: left;">Profile</h1>
{% if messages %}
{% for message in messages %}
<p style="margin-bottom: 1rem; color: {% if message.tags == 'error' %}#ff6666{% else %}var(--primary-color){% endif %};">
{{ message }}
</p>
{% endfor %}
{% endif %}
<div class="card" style="max-width: 600px; margin-bottom: 2rem;">
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Account Type</h2>
<p style="color: var(--text-muted); margin-bottom: 1rem;">
Your account is currently: <strong>{{ profile.get_user_type_display }}</strong>
</p>
<div class="card" style="max-width: 600px;">
<form method="post">
{% csrf_token %}
{{ form.as_p }}
{% if can_edit_type %}
<button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button>
{% endif %}
</form>
</div>
{% if employee_form %}
<div class="card" style="max-width: 600px;">
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Employee Details</h2>
{{ employee_form.as_p }}
<p style="color: var(--text-muted); font-size: 0.9rem;">Contact an admin to update employee details.</p>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -9,13 +9,11 @@
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h1 class="section-title" style="margin-bottom: 0;">All Time Logs</h1>
<div>
<a href="{% url 'financial_home' %}" class="btn"
<a href="{% url 'financial_index' %}" class="btn"
style="padding: 0.5rem 1.5rem; font-size: 0.9rem; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1);">Back
to Dashboard</a>
{% if can_edit_logs %}
<a href="{% url 'Timekeeping' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Log New
Time</a>
{% endif %}
</div>
</div>
@@ -146,7 +144,6 @@
<td>{{ log.end_time|default_if_none:"" }}</td>
<td>{{ log.hour }}</td>
<td>
{% if can_edit_logs %}
<a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan"
style="margin-right: 10px;">Edit</a>
<form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;"
@@ -155,9 +152,6 @@
<button type="submit"
style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button>
</form>
{% else %}
<span style="color: var(--text-muted);">Read only</span>
{% endif %}
</td>
</tr>
{% empty %}
+2 -158
View File
@@ -1,159 +1,3 @@
from django.contrib.auth.models import User
from django.test import Client, TestCase
from django.urls import reverse
from django.test import TestCase
from financial.models import (
AddressModel,
ChargeNumber,
Contract,
Employee,
TimeCard,
TimeCardCell,
UserProfile,
set_user_type,
)
from financial.permissions import get_employees, is_client_user, is_employee_user
class UserProfileSignalTests(TestCase):
def test_new_user_gets_client_profile_not_employee(self):
user = User.objects.create_user(username="newbie", password="pass")
self.assertTrue(UserProfile.objects.filter(user=user, user_type=UserProfile.UserType.CLIENT).exists())
self.assertFalse(Employee.objects.filter(user=user).exists())
class SetUserTypeTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="worker", password="pass")
UserProfile.objects.filter(user=self.user).delete()
def test_set_employee_creates_employee_record(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
self.assertTrue(Employee.objects.filter(user=self.user).exists())
self.assertEqual(self.user.profile.user_type, UserProfile.UserType.EMPLOYEE)
def test_set_client_removes_employee_without_time_entries(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
set_user_type(self.user, UserProfile.UserType.CLIENT)
self.assertFalse(Employee.objects.filter(user=self.user).exists())
self.assertEqual(
UserProfile.objects.get(user=self.user).user_type,
UserProfile.UserType.CLIENT,
)
def test_cannot_set_client_with_time_entries(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
employee = Employee.objects.get(user=self.user)
contract = Contract.objects.create(
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
name="Test Contract",
)
charge = ChargeNumber.objects.create(
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
contract=contract,
)
time_card = TimeCard.objects.create(employee=employee)
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=2.0)
with self.assertRaises(ValueError):
set_user_type(self.user, UserProfile.UserType.CLIENT)
class EmployeeFilterTests(TestCase):
def setUp(self):
self.employee_user = User.objects.create_user(username="emp", password="pass")
self.client_user = User.objects.create_user(username="cli", password="pass")
set_user_type(self.employee_user, UserProfile.UserType.EMPLOYEE)
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
def test_get_employees_excludes_clients(self):
employees = list(get_employees())
self.assertEqual(len(employees), 1)
self.assertEqual(employees[0].user, self.employee_user)
def test_permission_helpers(self):
self.assertTrue(is_employee_user(self.employee_user))
self.assertFalse(is_employee_user(self.client_user))
self.assertTrue(is_client_user(self.client_user))
self.assertFalse(is_client_user(self.employee_user))
class FinancialAccessTests(TestCase):
def setUp(self):
self.client = Client()
self.admin = User.objects.create_superuser(username="admin", password="pass", email="a@test.com")
self.employee = User.objects.create_user(username="employee", password="pass")
self.client_user = User.objects.create_user(username="client", password="pass")
set_user_type(self.employee, UserProfile.UserType.EMPLOYEE)
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
contract = Contract.objects.create(
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
name="C1",
budget_hours=100,
)
charge = ChargeNumber.objects.create(
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
contract=contract,
)
emp_record = Employee.objects.get(user=self.employee)
time_card = TimeCard.objects.create(employee=emp_record)
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=4.0)
def test_client_can_view_reports_readonly(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("client_reports"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "C1")
def test_client_cannot_log_time(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("Timekeeping"))
self.assertEqual(response.status_code, 403)
def test_client_can_view_time_logs_without_edit(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("time_logs"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Read only")
self.assertNotContains(response, 'href="/financial/time_logs/')
def test_employee_can_access_timekeeping(self):
self.client.login(username="employee", password="pass")
response = self.client.get(reverse("Timekeeping"))
self.assertEqual(response.status_code, 200)
def test_client_redirected_from_financial_home_to_reports(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("financial_home"))
self.assertRedirects(response, reverse("client_reports"))
def test_admin_can_manage_users(self):
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("manage_users"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("manage_users"), {
"user": self.client_user.id,
"user_type": UserProfile.UserType.EMPLOYEE,
})
self.assertRedirects(response, reverse("manage_users"))
self.client_user.refresh_from_db()
self.assertEqual(self.client_user.profile.user_type, UserProfile.UserType.EMPLOYEE)
def test_time_logs_only_lists_employees(self):
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("time_logs"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "employee")
employees = response.context["employees"]
self.assertEqual(employees.count(), 1)
def test_new_user_not_in_employee_dropdown(self):
extra = User.objects.create_user(username="extra", password="pass")
set_user_type(extra, UserProfile.UserType.CLIENT)
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("time_logs"))
employees = response.context["employees"]
usernames = [e.user.username for e in employees]
self.assertIn("employee", usernames)
self.assertNotIn("extra", usernames)
# Create your tests here.
+1 -3
View File
@@ -3,8 +3,7 @@ from django.urls import path
from . import views
urlpatterns = [
path("", views.financial_home, name="financial_home"),
path("dashboard", views.index, name="financial_index"),
path("", views.index, name="financial_index"),
path("timekeeping", views.timekeeping, name="Timekeeping"),
path("time_logs", views.time_logs, name="time_logs"),
path("time_logs/<int:log_id>/edit", views.edit_time_log, name="edit_time_log"),
@@ -19,6 +18,5 @@ urlpatterns = [
#path("contracts/<int:contract_id>/", views.contract_detail, name="contract"),
path("procurements", views.procurement, name="procurements"),
path("profile", views.profile, name="profile"),
path("manage_users", views.manage_users, name="manage_users"),
path("client_reports", views.client_reports, name="client_reports"),
]
+32 -142
View File
@@ -1,41 +1,23 @@
from django.shortcuts import render, redirect
from django.contrib.auth.models import User
from django.contrib import messages
from django.contrib.auth.decorators import user_passes_test
from .forms import EmployeeForm, ContractForm, ChargeNumberForm, TimeLogForm, NewEmployeeForm
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee
from django.utils import timezone
from django.db.models import Sum
from datetime import timedelta
import json
from .forms import (
EmployeeForm,
ContractForm,
ChargeNumberForm,
TimeLogForm,
NewEmployeeForm,
UserProfileForm,
AdminUserTypeForm,
)
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee, UserProfile
from .permissions import (
financial_admin_required,
financial_access_required,
financial_write_required,
get_employees,
get_user_employee,
is_client_user,
is_financial_admin,
can_write_financials,
)
def is_admin(user):
return user.is_active and user.is_superuser
@financial_admin_required
@user_passes_test(is_admin)
def index(request):
contracts = Contract.objects.all()
for c in contracts:
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
c.total_logged = total if total else 0.0
employees = get_employees()
employees = Employee.objects.all()
employee_data = []
for e in employees:
contract_hours = []
@@ -46,20 +28,10 @@ def index(request):
return render(request, "financial/index.html", {
'contracts': contracts,
'employee_data': employee_data,
'employee_data': employee_data
})
@financial_access_required
def financial_home(request):
if is_financial_admin(request.user):
return redirect('financial_index')
if is_client_user(request.user):
return redirect('client_reports')
return redirect('Timekeeping')
@financial_admin_required
@user_passes_test(is_admin)
def new_employee(request):
if request.method == "POST":
form = NewEmployeeForm(request.POST)
@@ -70,8 +42,7 @@ def new_employee(request):
form = NewEmployeeForm()
return render(request, 'financial/new_employee.html', {"form": form})
@financial_admin_required
@user_passes_test(is_admin)
def contracts(request):
contracts_list = Contract.objects.all()
today = timezone.now().date()
@@ -131,8 +102,7 @@ def contracts(request):
'chart_data_json': json.dumps(chart_data_list)
})
@financial_admin_required
@user_passes_test(is_admin)
def contract_detail(request, contract_slug):
contract = Contract.objects.filter(slug=contract_slug).first()
@@ -152,6 +122,7 @@ def contract_detail(request, contract_slug):
lines_str = "\n".join(mermaid_gantt_lines)
mermaid_gantt = f"gantt\n title {contract.name} Charge Numbers Timeline\n dateFormat YYYY-MM-DD\n section Charge Numbers\n{lines_str}"
# --- EVM Data ---
evm = contract.get_evm_data() if contract else {}
evm_chart_json = json.dumps({
'time_series': evm.get('time_series', []),
@@ -178,8 +149,7 @@ def contract_detail(request, contract_slug):
'evm_chart_json': evm_chart_json,
})
@financial_admin_required
@user_passes_test(is_admin)
def new_contract(request):
if request.method == "POST":
form = ContractForm(request.POST)
@@ -190,42 +160,27 @@ def new_contract(request):
form = ContractForm()
return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True})
@financial_write_required
@user_passes_test(is_admin)
def timekeeping(request):
employee = get_user_employee(request.user)
if not employee and not is_financial_admin(request.user):
messages.error(request, "Only employees can log time.")
return redirect('financial_home')
if request.method == "POST":
form = TimeLogForm(request.POST)
if form.is_valid():
if is_financial_admin(request.user) and not employee:
messages.error(request, "Admin must have an Employee profile to log time here.")
return redirect('time_logs')
employee, _ = Employee.objects.get_or_create(user=request.user)
time_card, _ = TimeCard.objects.get_or_create(
employee=employee,
startDate=timezone.now().date(),
endDate=timezone.now().date(),
)
time_card, _ = TimeCard.objects.get_or_create(employee=employee, startDate=timezone.now().date(), endDate=timezone.now().date())
cell = form.save(commit=False)
cell.timeCard = time_card
cell.save()
return redirect('financial_home')
return redirect('financial_index')
else:
form = TimeLogForm()
return render(request, 'financial/timekeeping.html', {'form': form})
@financial_access_required
@user_passes_test(is_admin)
def time_logs(request):
logs = TimeCardCell.objects.select_related(
'timeCard__employee__user',
'charge_number__contract',
).filter(
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
).order_by('-date', '-created')
employee_ids = []
@@ -276,13 +231,12 @@ def time_logs(request):
return render(request, 'financial/time_logs.html', {
'logs': logs,
'employees': get_employees().select_related('user').order_by('user__last_name', 'user__first_name'),
'employees': Employee.objects.select_related('user').order_by('user__last_name', 'user__first_name'),
'contracts': Contract.objects.order_by('name'),
'charge_numbers': charge_numbers,
'contract_totals': contract_totals,
'charge_number_totals': charge_number_totals,
'grand_total': grand_total,
'can_edit_logs': can_write_financials(request.user),
'filters': {
'employees': [str(eid) for eid in employee_ids],
'month': month or '',
@@ -291,8 +245,7 @@ def time_logs(request):
},
})
@financial_write_required
@user_passes_test(is_admin)
def edit_time_log(request, log_id):
log_entry = TimeCardCell.objects.filter(id=log_id).first()
if not log_entry:
@@ -308,8 +261,7 @@ def edit_time_log(request, log_id):
return render(request, 'financial/edit_time_log.html', {'form': form, 'log': log_entry})
@financial_write_required
@user_passes_test(is_admin)
def delete_time_log(request, log_id):
if request.method == "POST":
log_entry = TimeCardCell.objects.filter(id=log_id).first()
@@ -317,24 +269,16 @@ def delete_time_log(request, log_id):
log_entry.delete()
return redirect('time_logs')
@financial_access_required
@user_passes_test(is_admin)
def client_reports(request):
contracts = Contract.objects.all()
for c in contracts:
total = TimeCardCell.objects.filter(
charge_number__contract=c,
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
).aggregate(Sum('hour'))['hour__sum']
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
c.total_logged = total if total else 0.0
c.remaining_budget = c.budget_hours - c.total_logged
return render(request, 'financial/reports.html', {
'contracts': contracts,
'read_only': is_client_user(request.user),
})
return render(request, 'financial/reports.html', {'contracts': contracts})
@financial_admin_required
@user_passes_test(is_admin)
def update_charge_number(request, charge_number_slug):
charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first()
if not charge_number:
@@ -353,8 +297,7 @@ def update_charge_number(request, charge_number_slug):
'charge_number': charge_number,
})
@financial_admin_required
@user_passes_test(is_admin)
def new_charge_number(request, contract_slug):
contract = Contract.objects.filter(slug=contract_slug).first()
if request.method == "POST":
@@ -366,72 +309,19 @@ def new_charge_number(request, contract_slug):
return redirect('contract_detail', contract_slug=contract.slug)
return redirect('contract_detail', contract_slug=contract_slug)
@financial_admin_required
@user_passes_test(is_admin)
def timeapproval(request):
return render(request, 'financial/not_created.html', {})
@financial_admin_required
@user_passes_test(is_admin)
def chargenumber(request):
return render(request, 'financial/not_created.html', {})
@financial_admin_required
@user_passes_test(is_admin)
def procurement(request):
return render(request, 'financial/procurement.html', {})
@financial_access_required
@user_passes_test(is_admin)
def profile(request):
profile_obj, _ = UserProfile.objects.get_or_create(user=request.user)
can_edit_type = is_financial_admin(request.user)
if request.method == "POST" and can_edit_type:
form = UserProfileForm(request.POST, instance=profile_obj)
if form.is_valid():
from .models import set_user_type
try:
set_user_type(request.user, form.cleaned_data['user_type'])
messages.success(request, "Profile updated.")
except ValueError as exc:
messages.error(request, str(exc))
return redirect('profile')
else:
form = UserProfileForm(instance=profile_obj)
if not can_edit_type:
form.fields['user_type'].disabled = True
employee = Employee.objects.filter(user=request.user).first()
employee_form = None
if employee and profile_obj.is_employee():
employee_form = EmployeeForm(instance=employee)
return render(request, 'financial/profile.html', {
'form': form,
'employee_form': employee_form,
'profile': profile_obj,
'can_edit_type': can_edit_type,
})
@financial_admin_required
def manage_users(request):
if request.method == "POST":
form = AdminUserTypeForm(request.POST)
if form.is_valid():
from .models import set_user_type
try:
set_user_type(form.cleaned_data['user'], form.cleaned_data['user_type'])
messages.success(request, "User type updated.")
return redirect('manage_users')
except ValueError as exc:
messages.error(request, str(exc))
else:
form = AdminUserTypeForm()
users = User.objects.select_related('profile').order_by('username')
return render(request, 'financial/manage_users.html', {
'form': form,
'users': users,
})
form = EmployeeForm()
return render(request, 'financial/profile.html', {'form': form})
-28
View File
@@ -50,31 +50,3 @@ def webmcp_context(request):
'webmcp_services_json': json.dumps(services),
'webmcp_pages_json': json.dumps(page_lookup),
}
def financial_access(request):
user = request.user
if not user.is_authenticated:
return {
'is_financial_admin': False,
'is_employee_user': False,
'is_client_user': False,
'can_write_financials': False,
'has_financial_access': False,
}
from financial.permissions import (
can_write_financials,
has_financial_access,
is_client_user,
is_employee_user,
is_financial_admin,
)
return {
'is_financial_admin': is_financial_admin(user),
'is_employee_user': is_employee_user(user),
'is_client_user': is_client_user(user),
'can_write_financials': can_write_financials(user),
'has_financial_access': has_financial_access(user),
}
+1 -9
View File
@@ -89,11 +89,9 @@
<li><a href="{% url 'planning:board_view' %}"
class="{% if 'planning' in request.path %}active{% endif %}"
data-tianji-event="nav_planning">Planning</a></li>
{% if has_financial_access %}
<li><a href="{% url 'financial_home' %}"
<li><a href="{% url 'financial_index' %}"
class="{% if 'financial' in request.path %}active{% endif %}"
data-tianji-event="nav_financials">Financials</a></li>
{% endif %}
<li class="dropdown" id="user-profile-dropdown">
<button type="button" class="profile-icon-link" aria-label="Account menu for {{ user.get_full_name|default:user.username }}"
aria-expanded="false" aria-haspopup="true" aria-controls="profile-menu">
@@ -105,12 +103,6 @@
</button>
<ul class="dropdown-content profile-dropdown-content" id="profile-menu" role="menu">
<li class="profile-name-item">{{ user.get_full_name|default:user.username }}</li>
{% if has_financial_access %}
<li><a href="{% url 'profile' %}">Profile</a></li>
{% endif %}
{% if is_financial_admin %}
<li><a href="{% url 'manage_users' %}">Manage Users</a></li>
{% endif %}
<li><a href="{% url 'change_password' %}">Change Password</a></li>
<li>
<form action="{% url 'logout' %}" method="post" style="margin: 0;">