Author SHA1 Message Date
westfarn eed8852897 Fix logout 403 by setting CSRF trusted origins (#18)
CI / test (pull_request) Successful in 11s
Unit Tests / test (pull_request) Successful in 10s
Django 5 rejects HTTPS POSTs without CSRF_TRUSTED_ORIGINS; derive them from ALLOWED_HOSTS and trust the reverse-proxy TLS headers in prod/beta.
2026-07-10 13:12:17 -05:00
westfarn c97bd16445 Require auth for /preview_email/ (#16) (#17)
Unit Tests / test (push) Successful in 10s
## Summary
- Closes #16
- Confirms `/preview_email/<pk>/` requires an authenticated user (`@login_required` already on the view)
- Adds unit tests for unauthenticated redirect and authenticated preview access

## Test plan
- [x] `python manage.py test public.tests.PreviewEmailAuthTests`
- [ ] Manually hit `/preview_email/1/` logged out → redirect to `/accounts/login/`
- [ ] Log in and hit same URL → email preview renders

Reviewed-on: #17
2026-07-10 10:57:48 -07:00
westfarn 8ccf17655d updateing deploy for westfarn user
Unit Tests / test (push) Successful in 11s
2026-07-08 12:26:07 -05:00
westfarn 04d842d799 Test who runs the job
Unit Tests / test (push) Successful in 10s
2026-07-08 12:21:50 -05:00
westfarn ae0f8a8bc5 Updated deploy to use act_runner keys
Deploy Company Site / test (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Company Site / docker (push) Successful in 16s
Deploy Company Site / deploy (push) Failing after 0s
2026-07-08 11:51:33 -05:00
westfarn 16dfb3faae Updated deploy to use act_runner keys
Unit Tests / test (push) Successful in 10s
2026-07-08 11:46:43 -05:00
westfarn a5fa08d4a0 update docker unittests
Unit Tests / test (push) Successful in 10s
2026-07-08 06:12:21 -05:00
westfarn 9a383c0ee9 Update workflow to use server-infra
Unit Tests / test (push) Successful in 10s
2026-07-08 06:05:11 -05:00
westfarn 6f97f6084d Adding images
Unit Tests / test (push) Successful in 11s
2026-07-07 15:28:45 -05:00
westfarn 426cc82f04 Dockerize Django app with dev/beta/prod env config and uv (#6)
Unit Tests / test (push) Successful in 10s
## Summary

- Containerize the Django app with Docker and docker-compose (dev + production)
- Refactor settings into `dev` / `beta` / `prod` environments driven by environment variables
- Connect to PostgreSQL via `DATABASE_URL` or `DB_*` vars
- Migrate package management from pip to uv (`pyproject.toml`, `uv.lock`)
- Split Gitea workflows: PRs run unit tests only; pushes to `master` run tests, Docker validation, and deploy
- Update deploy script to rsync code, preserve server `.env`, validate config, and run Docker compose

Closes #4

## Test plan

- [x] `uv run python manage.py test` passes locally (10/10)
- [x] `DJANGO_ENV=beta` and `DJANGO_ENV=prod` load with correct logging levels
- [x] `scripts/validate-env.sh` rejects missing production variables
- [ ] `docker compose up --build` starts app + Postgres locally
- [ ] Containerized unit tests pass in CI Docker job
- [ ] Server `.env` created from `.env.prod.example` before first production deploy
- [ ] CI workflow runs on this PR (tests only, no deploy)

Reviewed-on: #6
2026-07-07 11:23:50 -07:00
westfarn 7dd5ec3be1 Add Employee vs Client user type and filter time/reports by employee (#14) (#15)
Unit Tests / test (push) Successful in 16s
## Summary

- Adds `UserProfile` model with mutually exclusive **Employee** / **Client** types
- Replaces auto-Employee signal with auto-Client profile on user creation
- Data migration: users with time log entries → Employee; others → Client (orphan Employee rows removed)
- Admin UI at `/financial/manage_users` to set any user's type; profile page shows current type
- **Employees** can log time; **Clients** get read-only access to reports and time logs
- Time logs, reports, and dashboard filter to employees only
- 14 new tests covering signals, type switching, access control, and filtering

## Design decisions (from issue Q&A)

1. Client login = read-only financial access (reports + time logs, no edit/log time)
2. Employee and Client are strictly mutually exclusive
3. Admins (superusers) can change type via Manage Users
4. Bulk migration applied for existing users

## Test plan

- [x] `python manage.py test financial.tests` (14 tests pass)
- [x] `python manage.py test public.tests` (21 tests pass)
- [ ] Run migration on staging: `python manage.py migrate`
- [ ] Verify admin can set user types at `/financial/manage_users`
- [ ] Verify employee can log time at `/financial/timekeeping`
- [ ] Verify client sees reports/time logs read-only, cannot log time
- [ ] Verify employee filter dropdown excludes clients

Closes #14

Reviewed-on: #15
2026-07-05 12:54:50 +00:00
27 changed files with 885 additions and 89 deletions
+2
View File
@@ -4,6 +4,8 @@ DJANGO_ENV=dev
DJANGO_DEBUG=true DJANGO_DEBUG=true
DJANGO_SECRET_KEY=change-me-for-local-development DJANGO_SECRET_KEY=change-me-for-local-development
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# Optional; when unset, http:// origins are derived for local hosts.
# DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000
# Database (docker-compose sets DATABASE_URL for the web service) # Database (docker-compose sets DATABASE_URL for the web service)
DATABASE_URL=postgres://company_site:company_site@db:5432/company_site DATABASE_URL=postgres://company_site:company_site@db:5432/company_site
+2
View File
@@ -7,6 +7,8 @@ DJANGO_ENV=prod
DJANGO_DEBUG=false DJANGO_DEBUG=false
DJANGO_SECRET_KEY=replace-with-a-long-random-secret DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=aimloperations.com,www.aimloperations.com DJANGO_ALLOWED_HOSTS=aimloperations.com,www.aimloperations.com
# Optional override; when unset, https:// origins are derived from DJANGO_ALLOWED_HOSTS.
# DJANGO_CSRF_TRUSTED_ORIGINS=https://aimloperations.com,https://www.aimloperations.com
# Logging (optional override; defaults: dev=DEBUG, beta=INFO, prod=WARNING) # Logging (optional override; defaults: dev=DEBUG, beta=INFO, prod=WARNING)
# DJANGO_LOG_LEVEL=WARNING # DJANGO_LOG_LEVEL=WARNING
+17 -31
View File
@@ -1,39 +1,21 @@
name: Deploy Company Site name: Deploy Company Site
# Deploy pipeline runs only on pushes to master (never on pull requests). # Runs after Unit Tests completes on master. Direct pushes only (not PRs).
on: on:
push: workflow_run:
workflows: [Unit Tests]
types: [completed]
branches: [master] branches: [master]
jobs: 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
docker: docker:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted runs-on: self-hosted
needs: test
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ gitea.event.workflow_run.head_sha }}
- name: Build Docker image - name: Build Docker image
run: docker compose build run: docker compose build
@@ -49,11 +31,15 @@ jobs:
docker compose down docker compose down
deploy: deploy:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted runs-on: self-hosted
needs: [test, docker] needs: docker
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
steps: steps:
- name: Checkout - name: Deploy company_site prod
uses: actions/checkout@v4 run: |
"${SERVER_INFRA_ROOT}/scripts/deploy.sh" \
- name: Deploy to live site --app company_site \
run: bash scripts/deploy.sh "${{ gitea.workspace }}" --env prod \
--ref "${{ gitea.event.workflow_run.head_sha }}"
+30
View File
@@ -0,0 +1,30 @@
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
+40 -1
View File
@@ -1,5 +1,6 @@
"""Shared Django settings for all environments.""" """Shared Django settings for all environments."""
import json
import os import os
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -22,6 +23,15 @@ def env_list(key: str, default: str = "") -> list[str]:
value = os.environ.get(key, default) value = os.environ.get(key, default)
if not value: if not value:
return [] 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()] return [item.strip() for item in value.split(",") if item.strip()]
@@ -72,6 +82,34 @@ WEBMCP_ENABLED = env_bool("WEBMCP_ENABLED", False)
allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*") allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*")
ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"] ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"]
def build_csrf_trusted_origins(
allowed_hosts: list[str], explicit: list[str] | None = None
) -> list[str]:
"""Build CSRF_TRUSTED_ORIGINS for Django 4+ Origin checks on HTTPS POSTs.
Prefer DJANGO_CSRF_TRUSTED_ORIGINS when set. Otherwise derive from ALLOWED_HOSTS:
https for public hosts, http for local loopback hosts.
"""
if explicit:
return explicit
local_hosts = {"localhost", "127.0.0.1", "0.0.0.0"}
origins: list[str] = []
for host in allowed_hosts:
if not host or host == "*" or host.startswith("."):
continue
hostname = host.split(":")[0]
scheme = "http" if hostname in local_hosts else "https"
origins.append(f"{scheme}://{host}")
return origins
CSRF_TRUSTED_ORIGINS = build_csrf_trusted_origins(
ALLOWED_HOSTS,
env_list("DJANGO_CSRF_TRUSTED_ORIGINS"),
)
INSTALLED_APPS = [ INSTALLED_APPS = [
"public.apps.PublicConfig", "public.apps.PublicConfig",
"financial.apps.FinancialConfig", "financial.apps.FinancialConfig",
@@ -113,6 +151,7 @@ TEMPLATES = [
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"public.context_processors.tianji_tracking", "public.context_processors.tianji_tracking",
"public.context_processors.webmcp_context", "public.context_processors.webmcp_context",
"public.context_processors.financial_access",
], ],
}, },
}, },
@@ -146,7 +185,7 @@ STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = { STORAGES = {
"staticfiles": { "staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", "BACKEND": "company_site.storage.TolerantManifestStaticFilesStorage",
}, },
} }
@@ -11,4 +11,10 @@ if DEBUG:
warnings.warn("DEBUG is enabled in beta environment.", stacklevel=1) warnings.warn("DEBUG is enabled in beta environment.", stacklevel=1)
# Same reverse-proxy assumptions as production when TLS is terminated upstream.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
LOGGING = build_logging_config(logging_level_for_env("beta"), "beta") LOGGING = build_logging_config(logging_level_for_env("beta"), "beta")
@@ -9,4 +9,10 @@ TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405
if not env("DJANGO_SECRET_KEY"): # noqa: F405 if not env("DJANGO_SECRET_KEY"): # noqa: F405
raise ValueError("DJANGO_SECRET_KEY must be set in production.") raise ValueError("DJANGO_SECRET_KEY must be set in production.")
# App sits behind a reverse proxy that terminates TLS (docker :8000).
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
LOGGING = build_logging_config(logging_level_for_env("prod"), "prod") LOGGING = build_logging_config(logging_level_for_env("prod"), "prod")
+32
View File
@@ -0,0 +1,32 @@
"""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
+6 -2
View File
@@ -1,6 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile
# Register your models here.
class ContractAdmin(admin.ModelAdmin): class ContractAdmin(admin.ModelAdmin):
pass pass
@@ -8,6 +7,10 @@ class ContractAdmin(admin.ModelAdmin):
class EmployeeAdmin(admin.ModelAdmin): class EmployeeAdmin(admin.ModelAdmin):
pass pass
class UserProfileAdmin(admin.ModelAdmin):
list_display = ("user", "user_type")
list_filter = ("user_type",)
class ChargeNumberAdmin(admin.ModelAdmin): class ChargeNumberAdmin(admin.ModelAdmin):
pass pass
@@ -19,6 +22,7 @@ class TimeCardCellAdmin(admin.ModelAdmin):
admin.site.register(Contract, ContractAdmin) admin.site.register(Contract, ContractAdmin)
admin.site.register(Employee, EmployeeAdmin) admin.site.register(Employee, EmployeeAdmin)
admin.site.register(UserProfile, UserProfileAdmin)
admin.site.register(ChargeNumber, ChargeNumberAdmin) admin.site.register(ChargeNumber, ChargeNumberAdmin)
admin.site.register(TimeCard, TimeCardAdmin) admin.site.register(TimeCard, TimeCardAdmin)
admin.site.register(TimeCardCell, TimeCardCellAdmin) admin.site.register(TimeCardCell, TimeCardCellAdmin)
+12 -1
View File
@@ -1,7 +1,8 @@
import datetime import datetime
from django import forms from django import forms
from django.contrib.auth.models import User
from django.forms import ModelForm 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): class NewEmployeeForm(ModelForm):
first_name = forms.CharField(max_length=30, required=False, label="First Name") first_name = forms.CharField(max_length=30, required=False, label="First Name")
@@ -37,6 +38,7 @@ class NewEmployeeForm(ModelForm):
employee.workAddress = address employee.workAddress = address
if commit: if commit:
employee.save() employee.save()
set_user_type(employee.user, UserProfile.UserType.EMPLOYEE)
return employee return employee
class EmployeeForm(ModelForm): class EmployeeForm(ModelForm):
@@ -44,6 +46,15 @@ class EmployeeForm(ModelForm):
model = Employee model = Employee
fields = ["user", "manager", "primaryAddress", "workAddress", "phoneNumber", "slary"] 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 ContractForm(ModelForm):
class Meta: class Meta:
model = Contract model = Contract
@@ -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),
]
+47
View File
@@ -243,6 +243,29 @@ class AddressModel(models.Model):
state = models.CharField(max_length=2) state = models.CharField(max_length=2)
zip_code = models.CharField(max_length=5) 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): class Employee(IdMixin, TimeMixin):
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True) manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
user = models.OneToOneField(User, on_delete=models.CASCADE) 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) 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
@@ -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
+8 -4
View File
@@ -2,10 +2,14 @@ from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.auth.models import User from django.contrib.auth.models import User
from .models import UserProfile
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_employee_for_user(sender, instance, created, **kwargs): def create_profile_for_user(sender, instance, created, **kwargs):
"""Auto-create an Employee record whenever a User is created.""" """Auto-create a UserProfile (default Client) whenever a User is created."""
from financial.models import Employee
if created: if created:
Employee.objects.get_or_create(user=instance) UserProfile.objects.get_or_create(
user=instance,
defaults={"user_type": UserProfile.UserType.CLIENT},
)
@@ -9,6 +9,7 @@
<h1 class="section-title">Dashboard</h1> <h1 class="section-title">Dashboard</h1>
<div class="card-grid" style="margin-bottom: 3rem;"> <div class="card-grid" style="margin-bottom: 3rem;">
{% if is_financial_admin %}
<a href="{% url 'contracts' %}" class="card" <a href="{% url 'contracts' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="contracts"> data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
<span class="card-title">View Contracts</span> <span class="card-title">View Contracts</span>
@@ -24,15 +25,22 @@
<span class="card-title">New Employee</span> <span class="card-title">New Employee</span>
<p class="card-text">Add a new personnel member to your organization.</p> <p class="card-text">Add a new personnel member to your organization.</p>
</a> </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" <a href="{% url 'Timekeeping' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping"> data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping">
<span class="card-title">Log Time</span> <span class="card-title">Log Time</span>
<p class="card-text">Record work hours against specific contracts.</p> <p class="card-text">Record work hours against specific contracts.</p>
</a> </a>
{% endif %}
<a href="{% url 'time_logs' %}" class="card" <a href="{% url 'time_logs' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="time_logs"> data-tianji-event="financial_nav" data-tianji-event-destination="time_logs">
<span class="card-title">Manage Time Logs</span> <span class="card-title">{% if can_write_financials %}Manage{% else %}View{% endif %} Time Logs</span>
<p class="card-text">Review and edit submitted time entries.</p> <p class="card-text">Review{% if can_write_financials %} and edit{% endif %} submitted time entries.</p>
</a> </a>
<a href="{% url 'client_reports' %}" class="card"> <a href="{% url 'client_reports' %}" class="card">
<span class="card-title">Client Reports</span> <span class="card-title">Client Reports</span>
@@ -0,0 +1,50 @@
{% 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,13 +7,36 @@
<div class="section"> <div class="section">
<div class="container"> <div class="container">
<h1 class="section-title" style="text-align: left;">Profile</h1> <h1 class="section-title" style="text-align: left;">Profile</h1>
<div class="card" style="max-width: 600px;">
{% 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>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
{% if can_edit_type %}
<button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button> <button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button>
{% endif %}
</form> </form>
</div> </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>
</div> </div>
{% endblock %} {% endblock %}
@@ -9,11 +9,13 @@
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;"> <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> <h1 class="section-title" style="margin-bottom: 0;">All Time Logs</h1>
<div> <div>
<a href="{% url 'financial_index' %}" class="btn" <a href="{% url 'financial_home' %}" 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 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> 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 <a href="{% url 'Timekeeping' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Log New
Time</a> Time</a>
{% endif %}
</div> </div>
</div> </div>
@@ -144,6 +146,7 @@
<td>{{ log.end_time|default_if_none:"" }}</td> <td>{{ log.end_time|default_if_none:"" }}</td>
<td>{{ log.hour }}</td> <td>{{ log.hour }}</td>
<td> <td>
{% if can_edit_logs %}
<a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan" <a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan"
style="margin-right: 10px;">Edit</a> style="margin-right: 10px;">Edit</a>
<form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;" <form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;"
@@ -152,6 +155,9 @@
<button type="submit" <button type="submit"
style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button> style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button>
</form> </form>
{% else %}
<span style="color: var(--text-muted);">Read only</span>
{% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
+158 -2
View File
@@ -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)
+3 -1
View File
@@ -3,7 +3,8 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ 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("timekeeping", views.timekeeping, name="Timekeeping"),
path("time_logs", views.time_logs, name="time_logs"), path("time_logs", views.time_logs, name="time_logs"),
path("time_logs/<int:log_id>/edit", views.edit_time_log, name="edit_time_log"), path("time_logs/<int:log_id>/edit", views.edit_time_log, name="edit_time_log"),
@@ -18,5 +19,6 @@ urlpatterns = [
#path("contracts/<int:contract_id>/", views.contract_detail, name="contract"), #path("contracts/<int:contract_id>/", views.contract_detail, name="contract"),
path("procurements", views.procurement, name="procurements"), path("procurements", views.procurement, name="procurements"),
path("profile", views.profile, name="profile"), path("profile", views.profile, name="profile"),
path("manage_users", views.manage_users, name="manage_users"),
path("client_reports", views.client_reports, name="client_reports"), path("client_reports", views.client_reports, name="client_reports"),
] ]
+142 -32
View File
@@ -1,23 +1,41 @@
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.models import User
from .forms import EmployeeForm, ContractForm, ChargeNumberForm, TimeLogForm, NewEmployeeForm from django.contrib import messages
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee
from django.utils import timezone from django.utils import timezone
from django.db.models import Sum from django.db.models import Sum
from datetime import timedelta from datetime import timedelta
import json import json
def is_admin(user): from .forms import (
return user.is_active and user.is_superuser 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)
@financial_admin_required
def index(request): def index(request):
contracts = Contract.objects.all() contracts = Contract.objects.all()
for c in contracts: 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).aggregate(Sum('hour'))['hour__sum']
c.total_logged = total if total else 0.0 c.total_logged = total if total else 0.0
employees = Employee.objects.all() employees = get_employees()
employee_data = [] employee_data = []
for e in employees: for e in employees:
contract_hours = [] contract_hours = []
@@ -28,10 +46,20 @@ def index(request):
return render(request, "financial/index.html", { return render(request, "financial/index.html", {
'contracts': contracts, '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): def new_employee(request):
if request.method == "POST": if request.method == "POST":
form = NewEmployeeForm(request.POST) form = NewEmployeeForm(request.POST)
@@ -42,7 +70,8 @@ def new_employee(request):
form = NewEmployeeForm() form = NewEmployeeForm()
return render(request, 'financial/new_employee.html', {"form": form}) return render(request, 'financial/new_employee.html', {"form": form})
@user_passes_test(is_admin)
@financial_admin_required
def contracts(request): def contracts(request):
contracts_list = Contract.objects.all() contracts_list = Contract.objects.all()
today = timezone.now().date() today = timezone.now().date()
@@ -102,7 +131,8 @@ def contracts(request):
'chart_data_json': json.dumps(chart_data_list) 'chart_data_json': json.dumps(chart_data_list)
}) })
@user_passes_test(is_admin)
@financial_admin_required
def contract_detail(request, contract_slug): def contract_detail(request, contract_slug):
contract = Contract.objects.filter(slug=contract_slug).first() 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) 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}" 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 = contract.get_evm_data() if contract else {}
evm_chart_json = json.dumps({ evm_chart_json = json.dumps({
'time_series': evm.get('time_series', []), 'time_series': evm.get('time_series', []),
@@ -149,7 +178,8 @@ def contract_detail(request, contract_slug):
'evm_chart_json': evm_chart_json, 'evm_chart_json': evm_chart_json,
}) })
@user_passes_test(is_admin)
@financial_admin_required
def new_contract(request): def new_contract(request):
if request.method == "POST": if request.method == "POST":
form = ContractForm(request.POST) form = ContractForm(request.POST)
@@ -160,27 +190,42 @@ def new_contract(request):
form = ContractForm() form = ContractForm()
return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True}) return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True})
@user_passes_test(is_admin)
@financial_write_required
def timekeeping(request): 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": if request.method == "POST":
form = TimeLogForm(request.POST) form = TimeLogForm(request.POST)
if form.is_valid(): if form.is_valid():
employee, _ = Employee.objects.get_or_create(user=request.user) 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()) time_card, _ = TimeCard.objects.get_or_create(
employee=employee,
startDate=timezone.now().date(),
endDate=timezone.now().date(),
)
cell = form.save(commit=False) cell = form.save(commit=False)
cell.timeCard = time_card cell.timeCard = time_card
cell.save() cell.save()
return redirect('financial_index') return redirect('financial_home')
else: else:
form = TimeLogForm() form = TimeLogForm()
return render(request, 'financial/timekeeping.html', {'form': form}) return render(request, 'financial/timekeeping.html', {'form': form})
@user_passes_test(is_admin)
@financial_access_required
def time_logs(request): def time_logs(request):
logs = TimeCardCell.objects.select_related( logs = TimeCardCell.objects.select_related(
'timeCard__employee__user', 'timeCard__employee__user',
'charge_number__contract', 'charge_number__contract',
).filter(
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
).order_by('-date', '-created') ).order_by('-date', '-created')
employee_ids = [] employee_ids = []
@@ -231,12 +276,13 @@ def time_logs(request):
return render(request, 'financial/time_logs.html', { return render(request, 'financial/time_logs.html', {
'logs': logs, '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'), 'contracts': Contract.objects.order_by('name'),
'charge_numbers': charge_numbers, 'charge_numbers': charge_numbers,
'contract_totals': contract_totals, 'contract_totals': contract_totals,
'charge_number_totals': charge_number_totals, 'charge_number_totals': charge_number_totals,
'grand_total': grand_total, 'grand_total': grand_total,
'can_edit_logs': can_write_financials(request.user),
'filters': { 'filters': {
'employees': [str(eid) for eid in employee_ids], 'employees': [str(eid) for eid in employee_ids],
'month': month or '', '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): def edit_time_log(request, log_id):
log_entry = TimeCardCell.objects.filter(id=log_id).first() log_entry = TimeCardCell.objects.filter(id=log_id).first()
if not log_entry: 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}) 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): def delete_time_log(request, log_id):
if request.method == "POST": if request.method == "POST":
log_entry = TimeCardCell.objects.filter(id=log_id).first() log_entry = TimeCardCell.objects.filter(id=log_id).first()
@@ -269,16 +317,24 @@ def delete_time_log(request, log_id):
log_entry.delete() log_entry.delete()
return redirect('time_logs') return redirect('time_logs')
@user_passes_test(is_admin)
@financial_access_required
def client_reports(request): def client_reports(request):
contracts = Contract.objects.all() contracts = Contract.objects.all()
for c in contracts: 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.total_logged = total if total else 0.0
c.remaining_budget = c.budget_hours - c.total_logged 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): def update_charge_number(request, charge_number_slug):
charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first() charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first()
if not charge_number: if not charge_number:
@@ -297,7 +353,8 @@ def update_charge_number(request, charge_number_slug):
'charge_number': charge_number, 'charge_number': charge_number,
}) })
@user_passes_test(is_admin)
@financial_admin_required
def new_charge_number(request, contract_slug): def new_charge_number(request, contract_slug):
contract = Contract.objects.filter(slug=contract_slug).first() contract = Contract.objects.filter(slug=contract_slug).first()
if request.method == "POST": 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)
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): def timeapproval(request):
return render(request, 'financial/not_created.html', {}) return render(request, 'financial/not_created.html', {})
@user_passes_test(is_admin)
@financial_admin_required
def chargenumber(request): def chargenumber(request):
return render(request, 'financial/not_created.html', {}) return render(request, 'financial/not_created.html', {})
@user_passes_test(is_admin)
@financial_admin_required
def procurement(request): def procurement(request):
return render(request, 'financial/procurement.html', {}) return render(request, 'financial/procurement.html', {})
@user_passes_test(is_admin)
@financial_access_required
def profile(request): def profile(request):
form = EmployeeForm() profile_obj, _ = UserProfile.objects.get_or_create(user=request.user)
return render(request, 'financial/profile.html', {'form': form}) 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,
})
+28
View File
@@ -50,3 +50,31 @@ def webmcp_context(request):
'webmcp_services_json': json.dumps(services), 'webmcp_services_json': json.dumps(services),
'webmcp_pages_json': json.dumps(page_lookup), '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),
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

+9 -1
View File
@@ -89,9 +89,11 @@
<li><a href="{% url 'planning:board_view' %}" <li><a href="{% url 'planning:board_view' %}"
class="{% if 'planning' in request.path %}active{% endif %}" class="{% if 'planning' in request.path %}active{% endif %}"
data-tianji-event="nav_planning">Planning</a></li> data-tianji-event="nav_planning">Planning</a></li>
<li><a href="{% url 'financial_index' %}" {% if has_financial_access %}
<li><a href="{% url 'financial_home' %}"
class="{% if 'financial' in request.path %}active{% endif %}" class="{% if 'financial' in request.path %}active{% endif %}"
data-tianji-event="nav_financials">Financials</a></li> data-tianji-event="nav_financials">Financials</a></li>
{% endif %}
<li class="dropdown" id="user-profile-dropdown"> <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 }}" <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"> aria-expanded="false" aria-haspopup="true" aria-controls="profile-menu">
@@ -103,6 +105,12 @@
</button> </button>
<ul class="dropdown-content profile-dropdown-content" id="profile-menu" role="menu"> <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> <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><a href="{% url 'change_password' %}">Change Password</a></li>
<li> <li>
<form action="{% url 'logout' %}" method="post" style="margin: 0;"> <form action="{% url 'logout' %}" method="post" style="margin: 0;">
+79 -1
View File
@@ -1,12 +1,90 @@
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth.models import User
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from .models import Contact from company_site.settings.base import build_csrf_trusted_origins
from .models import Contact, EmailMessage
from .seo import SERVICE_URL_NAMES, get_service_entries from .seo import SERVICE_URL_NAMES, get_service_entries
class CsrfTrustedOriginsTests(TestCase):
def test_derives_https_origins_from_public_hosts(self):
origins = build_csrf_trusted_origins(
["aimloperations.com", "www.aimloperations.com"]
)
self.assertEqual(
origins,
[
"https://aimloperations.com",
"https://www.aimloperations.com",
],
)
def test_derives_http_origins_for_local_hosts(self):
origins = build_csrf_trusted_origins(["localhost", "127.0.0.1"])
self.assertEqual(origins, ["http://localhost", "http://127.0.0.1"])
def test_explicit_origins_win(self):
origins = build_csrf_trusted_origins(
["aimloperations.com"],
["https://custom.example"],
)
self.assertEqual(origins, ["https://custom.example"])
class LogoutCsrfTests(TestCase):
def setUp(self):
self.client = Client(enforce_csrf_checks=True)
self.user = User.objects.create_user(username="logout_user", password="pass")
def test_logout_post_with_csrf_succeeds(self):
self.client.login(username="logout_user", password="pass")
self.client.get("/")
csrf = self.client.cookies["csrftoken"].value
response = self.client.post(
reverse("logout"),
{"csrfmiddlewaretoken": csrf},
)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.url, "/")
self.assertNotIn("_auth_user_id", self.client.session)
class PreviewEmailAuthTests(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(username="previewer", password="pass")
self.email = EmailMessage.objects.create(
subject="Preview subject",
body="Preview body content",
recipient="recipient@example.com",
)
self.url = reverse("preview_email", kwargs={"pk": self.email.pk})
def test_unauthenticated_user_is_redirected_to_login(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 302)
self.assertIn("/accounts/login/", response.url)
def test_authenticated_user_can_preview_email(self):
self.client.login(username="previewer", password="pass")
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Preview subject")
self.assertContains(response, "Preview body content")
@override_settings( @override_settings(
DEBUG=True, DEBUG=True,
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
+7 -4
View File
@@ -18,11 +18,14 @@ services:
build: . build: .
ports: ports:
- "8000:8000" - "8000:8000"
env_file: # No required env_file — CI has no .env. Defaults below; for local secrets:
- .env # docker compose --env-file .env up
environment: environment:
DJANGO_ENV: dev DJANGO_ENV: ${DJANGO_ENV:-dev}
DATABASE_URL: postgres://company_site:company_site@db:5432/company_site DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
DATABASE_URL: ${DATABASE_URL:-postgres://company_site:company_site@db:5432/company_site}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy