diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index a3dbb61..1cfa003 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -1,33 +1,20 @@ name: Deploy Company Site on: - push: + workflow_run: + workflows: [Unit Tests] + types: [completed] branches: [master] jobs: - test: - runs-on: self-hosted - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Python environment - run: | - python3 -m venv .venv - .venv/bin/pip install --upgrade pip - .venv/bin/pip install -r requirements.txt - - - name: Run unit tests - run: | - cd company_site - ../.venv/bin/python manage.py test - deploy: - needs: test + if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push' runs-on: self-hosted steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: ${{ gitea.event.workflow_run.head_sha }} - name: Deploy to live site run: bash scripts/deploy.sh "${{ gitea.workspace }}" diff --git a/.gitea/workflows/unittests.yml b/.gitea/workflows/unittests.yml new file mode 100644 index 0000000..961af66 --- /dev/null +++ b/.gitea/workflows/unittests.yml @@ -0,0 +1,25 @@ +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: Set up Python environment + run: | + python3 -m venv .venv + .venv/bin/pip install --upgrade pip + .venv/bin/pip install -r requirements.txt + + - name: Run unit tests + run: | + cd company_site + ../.venv/bin/python manage.py test diff --git a/company_site/company_site/settings.py b/company_site/company_site/settings.py index 0026c84..ef203bd 100644 --- a/company_site/company_site/settings.py +++ b/company_site/company_site/settings.py @@ -73,6 +73,7 @@ TEMPLATES = [ 'django.contrib.messages.context_processors.messages', 'public.context_processors.tianji_tracking', 'public.context_processors.webmcp_context', + 'public.context_processors.financial_access', ], }, }, diff --git a/company_site/financial/admin.py b/company_site/financial/admin.py index 70b2446..b04bf9b 100755 --- a/company_site/financial/admin.py +++ b/company_site/financial/admin.py @@ -1,6 +1,5 @@ from django.contrib import admin -from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell -# Register your models here. +from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile class ContractAdmin(admin.ModelAdmin): pass @@ -8,6 +7,10 @@ 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 @@ -19,6 +22,7 @@ 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) \ No newline at end of file +admin.site.register(TimeCardCell, TimeCardCellAdmin) diff --git a/company_site/financial/forms.py b/company_site/financial/forms.py index 86909b2..986d6e8 100644 --- a/company_site/financial/forms.py +++ b/company_site/financial/forms.py @@ -1,7 +1,8 @@ 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 +from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel, UserProfile, set_user_type class NewEmployeeForm(ModelForm): first_name = forms.CharField(max_length=30, required=False, label="First Name") @@ -37,6 +38,7 @@ class NewEmployeeForm(ModelForm): employee.workAddress = address if commit: employee.save() + set_user_type(employee.user, UserProfile.UserType.EMPLOYEE) return employee class EmployeeForm(ModelForm): @@ -44,6 +46,15 @@ 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 @@ -87,4 +98,4 @@ class TimeLogForm(ModelForm): if not cleaned_data.get('hour') and duration: cleaned_data['hour'] = duration - return cleaned_data \ No newline at end of file + return cleaned_data diff --git a/company_site/financial/migrations/0015_userprofile.py b/company_site/financial/migrations/0015_userprofile.py new file mode 100644 index 0000000..e72fd32 --- /dev/null +++ b/company_site/financial/migrations/0015_userprofile.py @@ -0,0 +1,79 @@ +# 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), + ] diff --git a/company_site/financial/models.py b/company_site/financial/models.py index b7e65f6..8f3478d 100755 --- a/company_site/financial/models.py +++ b/company_site/financial/models.py @@ -243,6 +243,29 @@ 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) @@ -279,6 +302,30 @@ 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 diff --git a/company_site/financial/permissions.py b/company_site/financial/permissions.py new file mode 100644 index 0000000..930da48 --- /dev/null +++ b/company_site/financial/permissions.py @@ -0,0 +1,76 @@ +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 diff --git a/company_site/financial/signals.py b/company_site/financial/signals.py index e5a62c9..fa03753 100644 --- a/company_site/financial/signals.py +++ b/company_site/financial/signals.py @@ -2,10 +2,14 @@ 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_employee_for_user(sender, instance, created, **kwargs): - """Auto-create an Employee record whenever a User is created.""" - from financial.models import Employee +def create_profile_for_user(sender, instance, created, **kwargs): + """Auto-create a UserProfile (default Client) whenever a User is created.""" if created: - Employee.objects.get_or_create(user=instance) + UserProfile.objects.get_or_create( + user=instance, + defaults={"user_type": UserProfile.UserType.CLIENT}, + ) diff --git a/company_site/financial/templates/financial/index.html b/company_site/financial/templates/financial/index.html index a283858..e80c8b1 100644 --- a/company_site/financial/templates/financial/index.html +++ b/company_site/financial/templates/financial/index.html @@ -9,6 +9,7 @@

Dashboard

+ {% if is_financial_admin %} View Contracts @@ -24,15 +25,22 @@ New Employee

Add a new personnel member to your organization.

+ + Manage Users +

Set Employee or Client type for user accounts.

+
+ {% endif %} + {% if can_write_financials %} Log Time

Record work hours against specific contracts.

+ {% endif %} - Manage Time Logs -

Review and edit submitted time entries.

+ {% if can_write_financials %}Manage{% else %}View{% endif %} Time Logs +

Review{% if can_write_financials %} and edit{% endif %} submitted time entries.

Client Reports diff --git a/company_site/financial/templates/financial/manage_users.html b/company_site/financial/templates/financial/manage_users.html new file mode 100644 index 0000000..9a11e0b --- /dev/null +++ b/company_site/financial/templates/financial/manage_users.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Manage Users - AI ML Operations{% endblock %} + +{% block content %} +
+
+

Manage Users

+ + {% if messages %} + {% for message in messages %} +

+ {{ message }} +

+ {% endfor %} + {% endif %} + +
+

Set User Type

+
+ {% csrf_token %} + {{ form.as_p }} + +
+
+ +
+ + + + + + + + + + {% for u in users %} + + + + + + {% endfor %} + +
UsernameNameType
{{ u.username }}{{ u.get_full_name|default:"—" }}{{ u.profile.get_user_type_display|default:"Client" }}
+
+
+
+{% endblock %} diff --git a/company_site/financial/templates/financial/profile.html b/company_site/financial/templates/financial/profile.html index 2eb37a5..fccc0cb 100644 --- a/company_site/financial/templates/financial/profile.html +++ b/company_site/financial/templates/financial/profile.html @@ -7,13 +7,36 @@

Profile

-
+ + {% if messages %} + {% for message in messages %} +

+ {{ message }} +

+ {% endfor %} + {% endif %} + +
+

Account Type

+

+ Your account is currently: {{ profile.get_user_type_display }} +

{% csrf_token %} {{ form.as_p }} + {% if can_edit_type %} + {% endif %}
+ + {% if employee_form %} +
+

Employee Details

+ {{ employee_form.as_p }} +

Contact an admin to update employee details.

+
+ {% endif %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/company_site/financial/templates/financial/time_logs.html b/company_site/financial/templates/financial/time_logs.html index f83ef07..995bf2e 100644 --- a/company_site/financial/templates/financial/time_logs.html +++ b/company_site/financial/templates/financial/time_logs.html @@ -9,11 +9,13 @@

All Time Logs

- Back to Dashboard + {% if can_edit_logs %} Log New Time + {% endif %}
@@ -144,6 +146,7 @@ {{ log.end_time|default_if_none:"" }} {{ log.hour }} + {% if can_edit_logs %} Edit
Delete
+ {% else %} + Read only + {% endif %} {% empty %} diff --git a/company_site/financial/tests.py b/company_site/financial/tests.py index 7ce503c..c78455d 100644 --- a/company_site/financial/tests.py +++ b/company_site/financial/tests.py @@ -1,3 +1,159 @@ -from django.test import TestCase +from django.contrib.auth.models import User +from django.test import Client, TestCase +from django.urls import reverse -# Create your tests here. +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) diff --git a/company_site/financial/urls.py b/company_site/financial/urls.py index 0497286..680ebf1 100644 --- a/company_site/financial/urls.py +++ b/company_site/financial/urls.py @@ -3,7 +3,8 @@ from django.urls import path from . import views urlpatterns = [ - path("", views.index, name="financial_index"), + path("", views.financial_home, name="financial_home"), + path("dashboard", views.index, name="financial_index"), path("timekeeping", views.timekeeping, name="Timekeeping"), path("time_logs", views.time_logs, name="time_logs"), path("time_logs//edit", views.edit_time_log, name="edit_time_log"), @@ -18,5 +19,6 @@ urlpatterns = [ #path("contracts//", 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"), ] \ No newline at end of file diff --git a/company_site/financial/views.py b/company_site/financial/views.py index dd11ed2..5341f17 100644 --- a/company_site/financial/views.py +++ b/company_site/financial/views.py @@ -1,23 +1,41 @@ from django.shortcuts import render, redirect -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.contrib.auth.models import User +from django.contrib import messages from django.utils import timezone from django.db.models import Sum from datetime import timedelta import json -def is_admin(user): - return user.is_active and user.is_superuser +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, +) -@user_passes_test(is_admin) -def index(request): + +@financial_admin_required +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 = Employee.objects.all() + employees = get_employees() employee_data = [] for e in employees: contract_hours = [] @@ -28,10 +46,20 @@ def index(request): return render(request, "financial/index.html", { 'contracts': contracts, - 'employee_data': employee_data + 'employee_data': employee_data, }) -@user_passes_test(is_admin) + +@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 def new_employee(request): if request.method == "POST": form = NewEmployeeForm(request.POST) @@ -42,7 +70,8 @@ def new_employee(request): form = NewEmployeeForm() return render(request, 'financial/new_employee.html', {"form": form}) -@user_passes_test(is_admin) + +@financial_admin_required def contracts(request): contracts_list = Contract.objects.all() today = timezone.now().date() @@ -102,7 +131,8 @@ def contracts(request): 'chart_data_json': json.dumps(chart_data_list) }) -@user_passes_test(is_admin) + +@financial_admin_required def contract_detail(request, contract_slug): contract = Contract.objects.filter(slug=contract_slug).first() @@ -122,7 +152,6 @@ 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', []), @@ -149,7 +178,8 @@ def contract_detail(request, contract_slug): 'evm_chart_json': evm_chart_json, }) -@user_passes_test(is_admin) + +@financial_admin_required def new_contract(request): if request.method == "POST": form = ContractForm(request.POST) @@ -160,27 +190,42 @@ def new_contract(request): form = ContractForm() return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True}) -@user_passes_test(is_admin) + +@financial_write_required 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(): - 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()) + 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') + + 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_index') + return redirect('financial_home') else: form = TimeLogForm() return render(request, 'financial/timekeeping.html', {'form': form}) -@user_passes_test(is_admin) + +@financial_access_required 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 = [] @@ -231,12 +276,13 @@ def time_logs(request): return render(request, 'financial/time_logs.html', { 'logs': logs, - 'employees': Employee.objects.select_related('user').order_by('user__last_name', 'user__first_name'), + 'employees': get_employees().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 '', @@ -245,7 +291,8 @@ def time_logs(request): }, }) -@user_passes_test(is_admin) + +@financial_write_required def edit_time_log(request, log_id): log_entry = TimeCardCell.objects.filter(id=log_id).first() if not log_entry: @@ -261,7 +308,8 @@ def edit_time_log(request, log_id): return render(request, 'financial/edit_time_log.html', {'form': form, 'log': log_entry}) -@user_passes_test(is_admin) + +@financial_write_required def delete_time_log(request, log_id): if request.method == "POST": log_entry = TimeCardCell.objects.filter(id=log_id).first() @@ -269,16 +317,24 @@ def delete_time_log(request, log_id): log_entry.delete() return redirect('time_logs') -@user_passes_test(is_admin) + +@financial_access_required def client_reports(request): contracts = Contract.objects.all() for c in contracts: - total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum'] + total = TimeCardCell.objects.filter( + charge_number__contract=c, + timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE, + ).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}) + return render(request, 'financial/reports.html', { + 'contracts': contracts, + 'read_only': is_client_user(request.user), + }) -@user_passes_test(is_admin) + +@financial_admin_required def update_charge_number(request, charge_number_slug): charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first() if not charge_number: @@ -297,7 +353,8 @@ def update_charge_number(request, charge_number_slug): 'charge_number': charge_number, }) -@user_passes_test(is_admin) + +@financial_admin_required def new_charge_number(request, contract_slug): contract = Contract.objects.filter(slug=contract_slug).first() if request.method == "POST": @@ -309,19 +366,72 @@ def new_charge_number(request, contract_slug): return redirect('contract_detail', contract_slug=contract.slug) return redirect('contract_detail', contract_slug=contract_slug) -@user_passes_test(is_admin) + +@financial_admin_required def timeapproval(request): return render(request, 'financial/not_created.html', {}) -@user_passes_test(is_admin) + +@financial_admin_required def chargenumber(request): return render(request, 'financial/not_created.html', {}) -@user_passes_test(is_admin) + +@financial_admin_required def procurement(request): return render(request, 'financial/procurement.html', {}) -@user_passes_test(is_admin) + +@financial_access_required def profile(request): - form = EmployeeForm() - return render(request, 'financial/profile.html', {'form': form}) \ No newline at end of file + 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, + }) diff --git a/company_site/public/context_processors.py b/company_site/public/context_processors.py index 77650c9..265b785 100644 --- a/company_site/public/context_processors.py +++ b/company_site/public/context_processors.py @@ -50,3 +50,31 @@ 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), + } diff --git a/company_site/public/templates/base.html b/company_site/public/templates/base.html index ae71a99..c478a32 100644 --- a/company_site/public/templates/base.html +++ b/company_site/public/templates/base.html @@ -89,9 +89,11 @@
  • Planning
  • -
  • Financials
  • + {% endif %}