Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07cba8bc7f | ||
|
|
8ccf17655d | ||
|
|
04d842d799 | ||
|
|
ae0f8a8bc5 | ||
|
|
16dfb3faae | ||
|
|
a5fa08d4a0 | ||
|
|
9a383c0ee9 | ||
|
|
6f97f6084d | ||
|
|
426cc82f04 | ||
|
|
7dd5ec3be1 |
+17
-31
@@ -1,39 +1,21 @@
|
||||
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:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: [Unit Tests]
|
||||
types: [completed]
|
||||
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
|
||||
|
||||
docker:
|
||||
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
||||
runs-on: self-hosted
|
||||
needs: test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ gitea.event.workflow_run.head_sha }}
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker compose build
|
||||
@@ -49,11 +31,15 @@ jobs:
|
||||
docker compose down
|
||||
|
||||
deploy:
|
||||
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
||||
runs-on: self-hosted
|
||||
needs: [test, docker]
|
||||
needs: docker
|
||||
env:
|
||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to live site
|
||||
run: bash scripts/deploy.sh "${{ gitea.workspace }}"
|
||||
- name: Deploy company_site prod
|
||||
run: |
|
||||
"${SERVER_INFRA_ROOT}/scripts/deploy.sh" \
|
||||
--app company_site \
|
||||
--env prod \
|
||||
--ref "${{ gitea.event.workflow_run.head_sha }}"
|
||||
|
||||
@@ -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
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Shared Django settings for all environments."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
@@ -22,6 +23,15 @@ def env_list(key: str, default: str = "") -> list[str]:
|
||||
value = os.environ.get(key, default)
|
||||
if not value:
|
||||
return []
|
||||
value = value.strip()
|
||||
# Accept a JSON array (e.g. '["a","b"]') as well as a comma-separated list.
|
||||
if value.startswith("["):
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except ValueError:
|
||||
parsed = None
|
||||
if isinstance(parsed, list):
|
||||
return [str(item).strip() for item in parsed if str(item).strip()]
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
@@ -113,6 +123,7 @@ TEMPLATES = [
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
"public.context_processors.tianji_tracking",
|
||||
"public.context_processors.webmcp_context",
|
||||
"public.context_processors.financial_access",
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -146,7 +157,7 @@ STATIC_URL = "static/"
|
||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||
STORAGES = {
|
||||
"staticfiles": {
|
||||
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||
"BACKEND": "company_site.storage.TolerantManifestStaticFilesStorage",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<h1 class="section-title">Dashboard</h1>
|
||||
|
||||
<div class="card-grid" style="margin-bottom: 3rem;">
|
||||
{% if is_financial_admin %}
|
||||
<a href="{% url 'contracts' %}" class="card"
|
||||
data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
|
||||
<span class="card-title">View Contracts</span>
|
||||
@@ -24,15 +25,22 @@
|
||||
<span class="card-title">New Employee</span>
|
||||
<p class="card-text">Add a new personnel member to your organization.</p>
|
||||
</a>
|
||||
<a href="{% url 'manage_users' %}" class="card">
|
||||
<span class="card-title">Manage Users</span>
|
||||
<p class="card-text">Set Employee or Client type for user accounts.</p>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if can_write_financials %}
|
||||
<a href="{% url 'Timekeeping' %}" class="card"
|
||||
data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping">
|
||||
<span class="card-title">Log Time</span>
|
||||
<p class="card-text">Record work hours against specific contracts.</p>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'time_logs' %}" class="card"
|
||||
data-tianji-event="financial_nav" data-tianji-event-destination="time_logs">
|
||||
<span class="card-title">Manage Time Logs</span>
|
||||
<p class="card-text">Review and edit submitted time entries.</p>
|
||||
<span class="card-title">{% if can_write_financials %}Manage{% else %}View{% endif %} Time Logs</span>
|
||||
<p class="card-text">Review{% if can_write_financials %} and edit{% endif %} submitted time entries.</p>
|
||||
</a>
|
||||
<a href="{% url 'client_reports' %}" class="card">
|
||||
<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="container">
|
||||
<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">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
{% if can_edit_type %}
|
||||
<button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if employee_form %}
|
||||
<div class="card" style="max-width: 600px;">
|
||||
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Employee Details</h2>
|
||||
{{ employee_form.as_p }}
|
||||
<p style="color: var(--text-muted); font-size: 0.9rem;">Contact an admin to update employee details.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -9,11 +9,13 @@
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||
<h1 class="section-title" style="margin-bottom: 0;">All Time Logs</h1>
|
||||
<div>
|
||||
<a href="{% url 'financial_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
|
||||
to Dashboard</a>
|
||||
{% if can_edit_logs %}
|
||||
<a href="{% url 'Timekeeping' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Log New
|
||||
Time</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -144,6 +146,7 @@
|
||||
<td>{{ log.end_time|default_if_none:"" }}</td>
|
||||
<td>{{ log.hour }}</td>
|
||||
<td>
|
||||
{% if can_edit_logs %}
|
||||
<a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan"
|
||||
style="margin-right: 10px;">Edit</a>
|
||||
<form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;"
|
||||
@@ -152,6 +155,9 @@
|
||||
<button type="submit"
|
||||
style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<span style="color: var(--text-muted);">Read only</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
|
||||
@@ -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,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/<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("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"),
|
||||
]
|
||||
+142
-32
@@ -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)
|
||||
|
||||
@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)
|
||||
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.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})
|
||||
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,
|
||||
})
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 99 KiB |
@@ -89,9 +89,11 @@
|
||||
<li><a href="{% url 'planning:board_view' %}"
|
||||
class="{% if 'planning' in request.path %}active{% endif %}"
|
||||
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 %}"
|
||||
data-tianji-event="nav_financials">Financials</a></li>
|
||||
{% endif %}
|
||||
<li class="dropdown" id="user-profile-dropdown">
|
||||
<button type="button" class="profile-icon-link" aria-label="Account menu for {{ user.get_full_name|default:user.username }}"
|
||||
aria-expanded="false" aria-haspopup="true" aria-controls="profile-menu">
|
||||
@@ -103,6 +105,12 @@
|
||||
</button>
|
||||
<ul class="dropdown-content profile-dropdown-content" id="profile-menu" role="menu">
|
||||
<li class="profile-name-item">{{ user.get_full_name|default:user.username }}</li>
|
||||
{% if has_financial_access %}
|
||||
<li><a href="{% url 'profile' %}">Profile</a></li>
|
||||
{% endif %}
|
||||
{% if is_financial_admin %}
|
||||
<li><a href="{% url 'manage_users' %}">Manage Users</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'change_password' %}">Change Password</a></li>
|
||||
<li>
|
||||
<form action="{% url 'logout' %}" method="post" style="margin: 0;">
|
||||
|
||||
@@ -1,12 +1,40 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import Client, TestCase, override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from .models import Contact
|
||||
from .models import Contact, EmailMessage
|
||||
from .seo import SERVICE_URL_NAMES, get_service_entries
|
||||
|
||||
|
||||
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(
|
||||
DEBUG=True,
|
||||
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
||||
|
||||
+7
-4
@@ -18,11 +18,14 @@ services:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file:
|
||||
- .env
|
||||
# No required env_file — CI has no .env. Defaults below; for local secrets:
|
||||
# docker compose --env-file .env up
|
||||
environment:
|
||||
DJANGO_ENV: dev
|
||||
DATABASE_URL: postgres://company_site:company_site@db:5432/company_site
|
||||
DJANGO_ENV: ${DJANGO_ENV:-dev}
|
||||
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:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
Reference in New Issue
Block a user