Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faca7b1084 |
@@ -2,9 +2,7 @@ name: Deploy Company Site
|
|||||||
|
|
||||||
# Deploy pipeline runs only on pushes to master (never on pull requests).
|
# Deploy pipeline runs only on pushes to master (never on pull requests).
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
push:
|
||||||
workflows: [Unit Tests]
|
|
||||||
types: [completed]
|
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -51,15 +49,11 @@ 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: [test, docker]
|
||||||
env:
|
|
||||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy company_site prod
|
- name: Checkout
|
||||||
run: |
|
uses: actions/checkout@v4
|
||||||
"${SERVER_INFRA_ROOT}/scripts/deploy.sh" \
|
|
||||||
--app company_site \
|
- name: Deploy to live site
|
||||||
--env prod \
|
run: bash scripts/deploy.sh "${{ gitea.workspace }}"
|
||||||
--ref "${{ gitea.event.workflow_run.head_sha }}"
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
name: Unit Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
run: |
|
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv sync --frozen
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
env:
|
|
||||||
DJANGO_ENV: dev
|
|
||||||
DJANGO_SECRET_KEY: test-secret-key
|
|
||||||
run: |
|
|
||||||
cd company_site
|
|
||||||
uv run python manage.py test
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""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
|
||||||
@@ -23,15 +22,6 @@ 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()]
|
||||||
|
|
||||||
|
|
||||||
@@ -123,7 +113,6 @@ 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",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -157,7 +146,7 @@ STATIC_URL = "static/"
|
|||||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
"staticfiles": {
|
"staticfiles": {
|
||||||
"BACKEND": "company_site.storage.TolerantManifestStaticFilesStorage",
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Custom static files storage.
|
|
||||||
|
|
||||||
WhiteNoise's manifest storage post-processes JS/CSS during ``collectstatic`` and
|
|
||||||
strictly resolves every referenced file, including ``sourceMappingURL`` comments
|
|
||||||
in vendored bundles. Some third-party assets reference ``.map`` files that are
|
|
||||||
not shipped, which makes ``collectstatic`` fail hard.
|
|
||||||
|
|
||||||
``TolerantManifestStaticFilesStorage`` leaves such unresolved references
|
|
||||||
untouched instead of raising, so a missing source map can't break the build.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from whitenoise.storage import CompressedManifestStaticFilesStorage
|
|
||||||
|
|
||||||
|
|
||||||
class TolerantManifestStaticFilesStorage(CompressedManifestStaticFilesStorage):
|
|
||||||
# Don't 500 at runtime when a {% static %} reference isn't in the manifest;
|
|
||||||
# fall back to the plain name (mirrors non-manifest storage behaviour).
|
|
||||||
manifest_strict = False
|
|
||||||
|
|
||||||
def _stored_name(self, name, hashed_files):
|
|
||||||
"""Tolerate missing references during collectstatic post-processing."""
|
|
||||||
try:
|
|
||||||
return super()._stored_name(name, hashed_files)
|
|
||||||
except ValueError:
|
|
||||||
return name
|
|
||||||
|
|
||||||
def stored_name(self, name):
|
|
||||||
"""Tolerate missing manifest entries at request time."""
|
|
||||||
try:
|
|
||||||
return super().stored_name(name)
|
|
||||||
except ValueError:
|
|
||||||
return name
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile
|
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell
|
||||||
|
# Register your models here.
|
||||||
|
|
||||||
class ContractAdmin(admin.ModelAdmin):
|
class ContractAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
@@ -7,10 +8,6 @@ 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
|
||||||
|
|
||||||
@@ -22,7 +19,6 @@ 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)
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
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, UserProfile, set_user_type
|
from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel
|
||||||
|
|
||||||
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")
|
||||||
@@ -38,7 +37,6 @@ 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):
|
||||||
@@ -46,15 +44,6 @@ 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
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# Generated manually for issue #14
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_user_profiles(apps, schema_editor):
|
|
||||||
User = apps.get_model("auth", "User")
|
|
||||||
UserProfile = apps.get_model("financial", "UserProfile")
|
|
||||||
Employee = apps.get_model("financial", "Employee")
|
|
||||||
TimeCardCell = apps.get_model("financial", "TimeCardCell")
|
|
||||||
|
|
||||||
employee_user_ids = set(
|
|
||||||
TimeCardCell.objects.values_list("timeCard__employee__user_id", flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
for user in User.objects.all():
|
|
||||||
if user.id in employee_user_ids:
|
|
||||||
UserProfile.objects.update_or_create(
|
|
||||||
user_id=user.id,
|
|
||||||
defaults={"user_type": "employee"},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
UserProfile.objects.update_or_create(
|
|
||||||
user_id=user.id,
|
|
||||||
defaults={"user_type": "client"},
|
|
||||||
)
|
|
||||||
Employee.objects.filter(user_id=user.id).delete()
|
|
||||||
|
|
||||||
|
|
||||||
def reverse_migrate_user_profiles(apps, schema_editor):
|
|
||||||
User = apps.get_model("auth", "User")
|
|
||||||
Employee = apps.get_model("financial", "Employee")
|
|
||||||
|
|
||||||
for user in User.objects.all():
|
|
||||||
Employee.objects.get_or_create(user_id=user.id)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
("financial", "0014_chargenumber_name"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="UserProfile",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"user_type",
|
|
||||||
models.CharField(
|
|
||||||
choices=[("employee", "Employee"), ("client", "Client")],
|
|
||||||
default="client",
|
|
||||||
max_length=10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
models.OneToOneField(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="profile",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.RunPython(migrate_user_profiles, reverse_migrate_user_profiles),
|
|
||||||
]
|
|
||||||
@@ -243,29 +243,6 @@ 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)
|
||||||
@@ -302,30 +279,6 @@ 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
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
from functools import wraps
|
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
|
||||||
from django.core.exceptions import PermissionDenied
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_profile(user):
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return None
|
|
||||||
from .models import UserProfile
|
|
||||||
|
|
||||||
return UserProfile.objects.filter(user_id=user.pk).first()
|
|
||||||
|
|
||||||
|
|
||||||
def is_financial_admin(user):
|
|
||||||
return user.is_active and user.is_superuser
|
|
||||||
|
|
||||||
|
|
||||||
def is_employee_user(user):
|
|
||||||
profile = get_user_profile(user)
|
|
||||||
return bool(profile and profile.is_employee())
|
|
||||||
|
|
||||||
|
|
||||||
def is_client_user(user):
|
|
||||||
profile = get_user_profile(user)
|
|
||||||
return bool(profile and profile.is_client())
|
|
||||||
|
|
||||||
|
|
||||||
def has_financial_access(user):
|
|
||||||
return is_financial_admin(user) or is_employee_user(user) or is_client_user(user)
|
|
||||||
|
|
||||||
|
|
||||||
def can_write_financials(user):
|
|
||||||
return is_financial_admin(user) or is_employee_user(user)
|
|
||||||
|
|
||||||
|
|
||||||
def get_employees():
|
|
||||||
from .models import Employee, UserProfile
|
|
||||||
|
|
||||||
return Employee.objects.filter(
|
|
||||||
user__profile__user_type=UserProfile.UserType.EMPLOYEE
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_employee(user):
|
|
||||||
from .models import Employee
|
|
||||||
|
|
||||||
if not is_employee_user(user):
|
|
||||||
return None
|
|
||||||
return Employee.objects.filter(user=user).first()
|
|
||||||
|
|
||||||
|
|
||||||
def financial_admin_required(view_func):
|
|
||||||
return user_passes_test(is_financial_admin)(view_func)
|
|
||||||
|
|
||||||
|
|
||||||
def financial_access_required(view_func):
|
|
||||||
@login_required
|
|
||||||
@wraps(view_func)
|
|
||||||
def _wrapped(request, *args, **kwargs):
|
|
||||||
if has_financial_access(request.user):
|
|
||||||
return view_func(request, *args, **kwargs)
|
|
||||||
raise PermissionDenied
|
|
||||||
|
|
||||||
return _wrapped
|
|
||||||
|
|
||||||
|
|
||||||
def financial_write_required(view_func):
|
|
||||||
@login_required
|
|
||||||
@wraps(view_func)
|
|
||||||
def _wrapped(request, *args, **kwargs):
|
|
||||||
if can_write_financials(request.user):
|
|
||||||
return view_func(request, *args, **kwargs)
|
|
||||||
raise PermissionDenied
|
|
||||||
|
|
||||||
return _wrapped
|
|
||||||
@@ -2,14 +2,10 @@ 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_profile_for_user(sender, instance, created, **kwargs):
|
def create_employee_for_user(sender, instance, created, **kwargs):
|
||||||
"""Auto-create a UserProfile (default Client) whenever a User is created."""
|
"""Auto-create an Employee record whenever a User is created."""
|
||||||
|
from financial.models import Employee
|
||||||
if created:
|
if created:
|
||||||
UserProfile.objects.get_or_create(
|
Employee.objects.get_or_create(user=instance)
|
||||||
user=instance,
|
|
||||||
defaults={"user_type": UserProfile.UserType.CLIENT},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
<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>
|
||||||
@@ -25,22 +24,15 @@
|
|||||||
<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">{% if can_write_financials %}Manage{% else %}View{% endif %} Time Logs</span>
|
<span class="card-title">Manage Time Logs</span>
|
||||||
<p class="card-text">Review{% if can_write_financials %} and edit{% endif %} submitted time entries.</p>
|
<p class="card-text">Review and edit 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>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Manage Users - AI ML Operations{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="section">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="section-title" style="text-align: left;">Manage Users</h1>
|
|
||||||
|
|
||||||
{% if messages %}
|
|
||||||
{% for message in messages %}
|
|
||||||
<p style="margin-bottom: 1rem; color: {% if message.tags == 'error' %}#ff6666{% else %}var(--primary-color){% endif %};">
|
|
||||||
{{ message }}
|
|
||||||
</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="card" style="max-width: 600px; margin-bottom: 2rem;">
|
|
||||||
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Set User Type</h2>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
{{ form.as_p }}
|
|
||||||
<button type="submit" class="btn" style="margin-top: 1rem;">Update User Type</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Username</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Type</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for u in users %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ u.username }}</td>
|
|
||||||
<td>{{ u.get_full_name|default:"—" }}</td>
|
|
||||||
<td>{{ u.profile.get_user_type_display|default:"Client" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -7,36 +7,13 @@
|
|||||||
<div class="section">
|
<div class="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,13 +9,11 @@
|
|||||||
<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_home' %}" class="btn"
|
<a href="{% url 'financial_index' %}" class="btn"
|
||||||
style="padding: 0.5rem 1.5rem; font-size: 0.9rem; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1);">Back
|
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>
|
||||||
|
|
||||||
@@ -146,7 +144,6 @@
|
|||||||
<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;"
|
||||||
@@ -155,9 +152,6 @@
|
|||||||
<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 %}
|
||||||
|
|||||||
@@ -1,159 +1,3 @@
|
|||||||
from django.contrib.auth.models import User
|
from django.test import TestCase
|
||||||
from django.test import Client, TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from financial.models import (
|
# Create your tests here.
|
||||||
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,8 +3,7 @@ from django.urls import path
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.financial_home, name="financial_home"),
|
path("", views.index, name="financial_index"),
|
||||||
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"),
|
||||||
@@ -19,6 +18,5 @@ 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"),
|
||||||
]
|
]
|
||||||
+32
-142
@@ -1,41 +1,23 @@
|
|||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.decorators import user_passes_test
|
||||||
from django.contrib import messages
|
from .forms import EmployeeForm, ContractForm, ChargeNumberForm, TimeLogForm, NewEmployeeForm
|
||||||
|
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
|
||||||
|
|
||||||
from .forms import (
|
def is_admin(user):
|
||||||
EmployeeForm,
|
return user.is_active and user.is_superuser
|
||||||
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 = get_employees()
|
employees = Employee.objects.all()
|
||||||
employee_data = []
|
employee_data = []
|
||||||
for e in employees:
|
for e in employees:
|
||||||
contract_hours = []
|
contract_hours = []
|
||||||
@@ -46,20 +28,10 @@ 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)
|
||||||
@@ -70,8 +42,7 @@ 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()
|
||||||
@@ -131,8 +102,7 @@ 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()
|
||||||
|
|
||||||
@@ -152,6 +122,7 @@ 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', []),
|
||||||
@@ -178,8 +149,7 @@ 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)
|
||||||
@@ -190,42 +160,27 @@ 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():
|
||||||
if is_financial_admin(request.user) and not employee:
|
employee, _ = Employee.objects.get_or_create(user=request.user)
|
||||||
messages.error(request, "Admin must have an Employee profile to log time here.")
|
|
||||||
return redirect('time_logs')
|
|
||||||
|
|
||||||
time_card, _ = TimeCard.objects.get_or_create(
|
time_card, _ = TimeCard.objects.get_or_create(employee=employee, startDate=timezone.now().date(), endDate=timezone.now().date())
|
||||||
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_home')
|
return redirect('financial_index')
|
||||||
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 = []
|
||||||
@@ -276,13 +231,12 @@ def time_logs(request):
|
|||||||
|
|
||||||
return render(request, 'financial/time_logs.html', {
|
return render(request, 'financial/time_logs.html', {
|
||||||
'logs': logs,
|
'logs': logs,
|
||||||
'employees': get_employees().select_related('user').order_by('user__last_name', 'user__first_name'),
|
'employees': Employee.objects.select_related('user').order_by('user__last_name', 'user__first_name'),
|
||||||
'contracts': Contract.objects.order_by('name'),
|
'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 '',
|
||||||
@@ -291,8 +245,7 @@ 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:
|
||||||
@@ -308,8 +261,7 @@ 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()
|
||||||
@@ -317,24 +269,16 @@ 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(
|
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
|
||||||
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', {
|
return render(request, 'financial/reports.html', {'contracts': contracts})
|
||||||
'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:
|
||||||
@@ -353,8 +297,7 @@ 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":
|
||||||
@@ -366,72 +309,19 @@ def new_charge_number(request, contract_slug):
|
|||||||
return redirect('contract_detail', contract_slug=contract.slug)
|
return redirect('contract_detail', contract_slug=contract.slug)
|
||||||
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):
|
||||||
profile_obj, _ = UserProfile.objects.get_or_create(user=request.user)
|
form = EmployeeForm()
|
||||||
can_edit_type = is_financial_admin(request.user)
|
return render(request, 'financial/profile.html', {'form': form})
|
||||||
|
|
||||||
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,31 +50,3 @@ 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),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -89,11 +89,9 @@
|
|||||||
<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>
|
||||||
{% if has_financial_access %}
|
<li><a href="{% url 'financial_index' %}"
|
||||||
<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">
|
||||||
@@ -105,12 +103,6 @@
|
|||||||
</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;">
|
||||||
|
|||||||
Reference in New Issue
Block a user