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 @@
Add a new personnel member to your organization.
+ + Manage Users +Set Employee or Client type for user accounts.
+ + {% endif %} + {% if can_write_financials %} Log TimeRecord 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 %} ++ {{ message }} +
+ {% endfor %} + {% endif %} + +| Username | +Name | +Type | +
|---|---|---|
| {{ u.username }} | +{{ u.get_full_name|default:"—" }} | +{{ u.profile.get_user_type_display|default:"Client" }} | +
+ {{ message }} +
+ {% endfor %} + {% endif %} + ++ Your account is currently: {{ profile.get_user_type_display }} +
Contact an admin to update employee details.
+