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

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

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

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

Closes #4

## Test plan

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

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

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

## Design decisions (from issue Q&A)

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

## Test plan

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

Closes #14

Reviewed-on: #15
2026-07-05 12:54:50 +00:00
westfarn 2fb5204614 Fix WebMCP schema validity on contact form (#13)
Deploy Company Site / test (push) Successful in 9s
Deploy Company Site / deploy (push) Successful in 4s
## Summary

Follow-up to PR #12 / Lighthouse agentic-browsing audit on `/contact` (score 0.88).

- Add `toolparamdescription` to all contact form fields for WebMCP schema validity
- Move reCAPTCHA outside the annotated `<form>` so `g-recaptcha-response` is excluded from the declarative tool schema
- Always render `toolname` / `tooldescription` on the contact form (not gated by `WEBMCP_ENABLED`)
- Override `django_recaptcha/widget_v3.html` to associate captcha with `form="contact-form"`
- Update docs with HTTPS Lighthouse commands

## Test plan

- [x] `python manage.py test public.tests` — 21 tests pass
- [ ] Deploy and re-run Lighthouse agentic-browsing on `https://aimloperations.com/contact`
- [ ] Confirm `webmcp-schema-validity` passes (expected overall score 1.0)

Reviewed-on: #13
2026-07-02 18:02:44 +00:00
westfarn bb5aa6de82 Add WebMCP tool registration for agentic browsing (#9) (#12)
Deploy Company Site / test (push) Successful in 10s
Deploy Company Site / deploy (push) Successful in 4s
## Summary

- Add `WEBMCP_ENABLED` setting and guarded `webmcp-tools.js` loader on public pages
- Register navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`) and contact tool (`submit_contact_inquiry`)
- Annotate contact form with declarative WebMCP attributes for Lighthouse form coverage
- Extend `PUBLIC_PAGE_ENTRIES` with summaries shared by SEO and WebMCP tools
- Add `docs/webmcp.md`, update `docs/agentic-browsing.md`, and regression tests

Closes #9

## Test plan

- [x] `python manage.py test public.tests` — 20 tests pass
- [ ] Set `WEBMCP_ENABLED=True`, enable Chrome experimental web platform features flag
- [ ] Verify homepage HTML includes `webmcp-config` and `webmcp-tools.js`
- [ ] Verify `/contact` form has `toolname="submit_contact_inquiry"`
- [ ] Run Lighthouse agentic-browsing on `/` and `/contact` with experimental flag
- [ ] Call `list_services` from WebMCP-capable Chrome on homepage
- [ ] Call `submit_contact_inquiry` on `/contact` with `DEBUG=True` (reCAPTCHA bypass)

Reviewed-on: #12
2026-07-02 16:57:47 +00:00
westfarn 7c9aba7e6c feature/7-tianji-acknowledge-banner (#11)
Deploy Company Site / test (push) Successful in 10s
Deploy Company Site / deploy (push) Successful in 5s
Reviewed-on: #11
2026-07-02 14:20:19 +00:00
westfarn b5ab8eb512 Switch Tianji tracking from opt-in consent to acknowledge notice. (#10)
Deploy Company Site / test (push) Successful in 10s
Deploy Company Site / deploy (push) Successful in 4s
Load analytics on page load so visitors who never interact still produce data, while an informational banner lets users acknowledge the practice. Migrate legacy declined and accepted consent values so prior opt-outs are cleared and tracking resumes.

Closes #7

Reviewed-on: #10
2026-07-02 12:04:10 +00:00
westfarn 846f1b8a37 Improve Lighthouse Agentic Browsing readiness (#8)
Deploy Company Site / test (push) Successful in 10s
Deploy Company Site / deploy (push) Successful in 5s
## Summary

- Add `robots.txt`, `sitemap.xml`, and `llms.txt` endpoints so crawlers and AI agents can discover public pages
- Fix accessibility tree issues: contact form labels, semantic nav/profile dropdown buttons, cookie consent dialog ARIA, and dead `href="#"` links
- Reduce layout shift on homepage/contact via hero min-heights, trusted-by marquee sizing, and fixed cookie banner
- Add regression tests and `docs/agentic-browsing.md` for Lighthouse agentic-browsing audits

Closes #5. Also addresses overlap with #3 (sitemap + robots.txt).

## Test plan

- [x] `python manage.py test public.tests` (14 tests pass)
- [ ] Verify `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200 in staging/prod
- [ ] Confirm contact form labels visible and submittable on `/contact`
- [ ] Run Lighthouse `--only-categories=agentic-browsing` on homepage and contact page
- [ ] Smoke test mobile nav dropdown and cookie consent banner

Reviewed-on: #8
2026-07-02 11:36:54 +00:00
westfarn c8574d76d4 Updated the consent banner
Deploy Company Site / test (push) Successful in 9s
Deploy Company Site / deploy (push) Successful in 4s
2026-06-29 05:14:18 -05:00
westfarn 63edf8c64f Fixing how the consent banner loads
Deploy Company Site / test (push) Successful in 10s
Deploy Company Site / deploy (push) Successful in 5s
2026-06-28 07:14:35 -05:00
westfarn 0f2b255b59 Tieing tianji to debug
Deploy Company Site / test (push) Successful in 10s
Deploy Company Site / deploy (push) Successful in 5s
2026-06-28 07:00:55 -05:00
westfarn 9c7e02e41e Add unittests and fix tracking error
Deploy Company Site / test (push) Successful in 10s
Deploy Company Site / deploy (push) Successful in 5s
2026-06-28 06:55:44 -05:00
westfarn 6e7bd25e29 Adding site tracking and consent to Tianji
Deploy Company Site / deploy (push) Successful in 4s
2026-06-28 06:47:52 -05:00
westfarn 7e448cf61a Update .gitea/workflows/deploy.yml
Deploy Company Site / deploy (push) Successful in 8s
2026-06-27 18:06:03 +00:00
westfarn afc197ecdf Update README.md 2026-06-27 18:04:27 +00:00
westfarn e625dc30ad Updated to get gitea action runner 2026-06-27 12:19:54 -05:00
westfarn 06ac8d6eca A bunch of updates 2026-06-20 06:05:50 -05:00
westfarn 8cd3aa5f84 EVM complete 2026-03-23 03:31:44 -05:00
westfarn c88e344198 Implemented financial tracking 2026-03-20 13:07:28 -05:00
westfarn 6fa4fc1f7c updated company logo 2025-12-08 10:49:18 -06:00
westfarn 2d9b987d03 seo optimization 2025-11-24 20:37:30 -06:00
westfarn 7d079607cb Fixing sending contact emails 2025-11-24 13:14:45 -06:00
westfarn 9b99ac92ee small updates and refined chat page 2025-11-24 06:03:32 -06:00
westfarn 9979c5bb9c site redesign 2025-11-22 06:55:40 -06:00
westfarn 75c6bf2a53 added site tracking 2025-04-21 06:37:57 -05:00
westfarn 68463c5013 Update company_site/public/admin.py 2025-03-25 20:52:18 +00:00
westfarn 150e240363 preview email 2025-03-24 13:06:45 -05:00
westfarn 1fe85321e0 Update company_site/public/templates/admin/public/emailmessage/change_form.html 2025-03-23 16:38:43 +00:00
westfarn 40cd595e90 added video to homepage and black as pre-commit 2025-03-11 07:49:52 -05:00
westfarn 1047d262c8 Update for the new redisn 2025-03-06 11:30:36 -06:00
146 changed files with 8846 additions and 1607 deletions
+4
View File
@@ -0,0 +1,4 @@
# Django Virtual Environment Rule
Always use the virtual environment located at `.venv` when running any Python scripts, pip commands, or Django management commands.
You can run commands using the virtual environment's Python executable directly (e.g., `.venv/bin/python manage.py ...`) or activate it first.
+4
View File
@@ -0,0 +1,4 @@
# Django Virtual Environment Rule
Always use the virtual environment located at `.venv` when running any Python scripts, pip commands, or Django management commands.
You can run commands using the virtual environment's Python executable directly (e.g., `.venv/bin/python manage.py ...`) or activate it first.
+11
View File
@@ -0,0 +1,11 @@
.git
.venv
**/__pycache__
*.py[cod]
db.sqlite3
.env
htmlcov/
.pytest_cache/
.mypy_cache/
*.log
staticfiles/
+39
View File
@@ -0,0 +1,39 @@
# Local development environment file (copy to .env).
# For production server values, use .env.prod.example instead.
DJANGO_ENV=dev
DJANGO_DEBUG=true
DJANGO_SECRET_KEY=change-me-for-local-development
DJANGO_ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# Optional; when unset, http:// origins are derived for local hosts.
# DJANGO_CSRF_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000
# Database (docker-compose sets DATABASE_URL for the web service)
DATABASE_URL=postgres://company_site:company_site@db:5432/company_site
# DB_HOST=db
# DB_PORT=5432
# DB_NAME=company_site
# DB_USER=company_site
# DB_PASSWORD=company_site
# Logging
# DJANGO_LOG_LEVEL=DEBUG
# Analytics
TIANJI_ENABLED=false
TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js
TIANJI_WEBSITE_ID=
# reCAPTCHA
RECAPTCHA_PUBLIC_KEY=
RECAPTCHA_PRIVATE_KEY=
# Email (SMTP2GO)
EMAIL_HOST=mail.smtp2go.com
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_PORT=2525
EMAIL_USE_TLS=true
# Gunicorn
GUNICORN_WORKERS=2
GUNICORN_BIND=0.0.0.0:8000
+43
View File
@@ -0,0 +1,43 @@
# Server-side production environment file.
# Copy to /home/westfarn/Documents/django_live_sites/Company_Site/.env
# This file is NOT deployed from git — rsync excludes .env so server values persist.
# Environment level: dev | beta | prod
DJANGO_ENV=prod
DJANGO_DEBUG=false
DJANGO_SECRET_KEY=replace-with-a-long-random-secret
DJANGO_ALLOWED_HOSTS=aimloperations.com,www.aimloperations.com
# Optional override; when unset, https:// origins are derived from DJANGO_ALLOWED_HOSTS.
# DJANGO_CSRF_TRUSTED_ORIGINS=https://aimloperations.com,https://www.aimloperations.com
# Logging (optional override; defaults: dev=DEBUG, beta=INFO, prod=WARNING)
# DJANGO_LOG_LEVEL=WARNING
# PostgreSQL — DATABASE_URL is used by Django; DB_* are used by the db container.
DATABASE_URL=postgres://company_site:replace-db-password@db:5432/company_site
DB_NAME=company_site
DB_USER=company_site
DB_PASSWORD=replace-db-password
# Analytics
TIANJI_ENABLED=true
TIANJI_TRACKER_URL=https://tianji.aimloperations.com/tracker.js
TIANJI_WEBSITE_ID=replace-with-production-website-id
# reCAPTCHA
RECAPTCHA_PUBLIC_KEY=replace-with-production-public-key
RECAPTCHA_PRIVATE_KEY=replace-with-production-private-key
# Email (SMTP2GO)
EMAIL_HOST=mail.smtp2go.com
EMAIL_HOST_USER=replace-with-smtp-user
EMAIL_HOST_PASSWORD=replace-with-smtp-password
EMAIL_PORT=2525
EMAIL_USE_TLS=true
# Gunicorn
GUNICORN_WORKERS=2
GUNICORN_BIND=0.0.0.0:8000
# Host port exposed by docker-compose.prod.yml
WEB_PORT=8000
+28
View File
@@ -0,0 +1,28 @@
name: CI
on:
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
+45
View File
@@ -0,0 +1,45 @@
name: Deploy Company Site
# Runs after Unit Tests completes on master. Direct pushes only (not PRs).
on:
workflow_run:
workflows: [Unit Tests]
types: [completed]
branches: [master]
jobs:
docker:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ gitea.event.workflow_run.head_sha }}
- name: Build Docker image
run: docker compose build
- name: Run containerized tests
run: |
docker compose up -d db
docker compose run --rm --entrypoint "" \
-e DJANGO_ENV=dev \
-e DJANGO_SECRET_KEY=test-secret-key \
-e DATABASE_URL=postgres://company_site:company_site@db:5432/company_site \
web uv run python manage.py test
docker compose down
deploy:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted
needs: docker
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
steps:
- name: Deploy company_site prod
run: |
"${SERVER_INFRA_ROOT}/scripts/deploy.sh" \
--app company_site \
--env prod \
--ref "${{ gitea.event.workflow_run.head_sha }}"
+30
View File
@@ -0,0 +1,30 @@
name: Unit Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
run: |
cd company_site
uv run python manage.py test
+1
View File
@@ -168,3 +168,4 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.runner
+5
View File
@@ -0,0 +1,5 @@
repos:
- repo: https://github.com/psf/black
rev: 25.1.0
hooks:
- id: black
+28
View File
@@ -0,0 +1,28 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/app/.venv
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq5 \
&& rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY company_site ./company_site
COPY scripts/docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
WORKDIR /app/company_site
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
+56 -1
View File
@@ -1,3 +1,58 @@
# company_site
Django site for company
Django site for AIML Operations.
## Local development (uv)
```bash
uv sync
cp .env.example .env
cd company_site
DJANGO_ENV=dev uv run python manage.py migrate
DJANGO_ENV=dev uv run python manage.py runserver
```
## Docker (dev + Postgres)
```bash
cp .env.example .env
docker compose up --build
```
App: http://localhost:8000
## Environments
Set `DJANGO_ENV` to one of:
| Value | DEBUG default | Logging level |
|-------|---------------|---------------|
| `dev` | true | DEBUG |
| `beta` | false | INFO |
| `prod` | false | WARNING |
Secrets and service config come from environment variables. See `.env.example`.
## CI / deploy workflows
| Workflow | Trigger | What runs |
|----------|---------|-----------|
| `.gitea/workflows/ci.yml` | Pull requests to `master` | Unit tests only |
| `.gitea/workflows/deploy.yml` | Push to `master` | Unit tests → Docker build/test → deploy |
Deploy never runs on pull requests. Uses separate workflow files (not job `if` conditions) so Gitea runners handle it reliably.
## Production deploy
Server keeps its own `.env` at the live site path. Deploy rsyncs code but **never overwrites `.env`**.
1. On the server, copy `.env.prod.example` to `.env` and fill in production values.
2. Run `bash scripts/validate-env.sh /path/to/.env` to verify required variables.
3. Push to `master` — the deploy workflow runs `scripts/deploy.sh`, which:
- rsyncs checkout to live site (preserving `.env`)
- validates environment variables
- `docker compose -f docker-compose.prod.yml build`
- `docker compose up -d`
- runs migrations in the web container
Legacy venv/systemd deploy: `DEPLOY_MODE=legacy bash scripts/deploy.sh <checkout>`.
Binary file not shown.
-149
View File
@@ -1,149 +0,0 @@
"""
Django settings for company_site project.
Generated by 'django-admin startproject' using Django 5.0.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-0$+hho_6%-(ud^t%0zos(q&i@2&)9m+u&dgj77&51g$m#hr^0s'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
'public.apps.PublicConfig',
'financial.apps.FinancialConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'phonenumber_field',
'django_recaptcha',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'company_site.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'company_site.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = 'static/'
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# # email settings
# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
# EMAIL_USE_TLS = True
# EMAIL_HOST = 'smtp.outlook.office365.com'#'smtp-mail.outlook.com'
# EMAIL_HOST_USERNAME = 'ryan@aimloperations.com'
# EMAIL_HOST_USER = 'ryan@aimloperations.com'
# EMAIL_HOST_PASSWORD = '!HopeThisW0rkz'
# EMAIL_PORT = 587
# SERVER_EMAIL = EMAIL_HOST_USER
# Recapcha Stuff
RECAPTCHA_PUBLIC_KEY = '6LdXRbopAAAAAL9NT7C2J3Fuu_b6rvhhsPyxTd9Z'
RECAPTCHA_PRIVATE_KEY = '6LdXRbopAAAAAPt31zdQJaOwLseognmZHZEHmWlt'
# SMTP2GO
EMAIL_HOST = 'mail.smtp2go.com'
EMAIL_HOST_USER = 'info.aimloperations.com'
EMAIL_HOST_PASSWORD = 'ZDErIII2sipNNVMz'
EMAIL_PORT = 2525
EMAIL_USE_TLS = True
@@ -0,0 +1,12 @@
"""Load environment-specific Django settings based on DJANGO_ENV."""
import os
_environment = os.environ.get("DJANGO_ENV", "dev").lower()
if _environment == "prod":
from .prod import * # noqa: F403
elif _environment == "beta":
from .beta import * # noqa: F403
else:
from .dev import * # noqa: F403
+210
View File
@@ -0,0 +1,210 @@
"""Shared Django settings for all environments."""
import json
import os
from pathlib import Path
from urllib.parse import urlparse
BASE_DIR = Path(__file__).resolve().parent.parent.parent
def env(key: str, default: str | None = None) -> str | None:
return os.environ.get(key, default)
def env_bool(key: str, default: bool = False) -> bool:
value = os.environ.get(key)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
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()]
def database_config() -> dict:
database_url = env("DATABASE_URL")
if database_url:
parsed = urlparse(database_url)
return {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": parsed.path.lstrip("/"),
"USER": parsed.username or "",
"PASSWORD": parsed.password or "",
"HOST": parsed.hostname or "",
"PORT": str(parsed.port or 5432),
}
}
if env("DB_HOST"):
return {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": env("DB_NAME", "company_site"),
"USER": env("DB_USER", "company_site"),
"PASSWORD": env("DB_PASSWORD", ""),
"HOST": env("DB_HOST"),
"PORT": env("DB_PORT", "5432"),
}
}
return {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
SECRET_KEY = env(
"DJANGO_SECRET_KEY",
"django-insecure-dev-only-change-me-before-production",
)
DEBUG = env_bool("DJANGO_DEBUG", False)
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG)
WEBMCP_ENABLED = env_bool("WEBMCP_ENABLED", False)
allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*")
ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"]
def build_csrf_trusted_origins(
allowed_hosts: list[str], explicit: list[str] | None = None
) -> list[str]:
"""Build CSRF_TRUSTED_ORIGINS for Django 4+ Origin checks on HTTPS POSTs.
Prefer DJANGO_CSRF_TRUSTED_ORIGINS when set. Otherwise derive from ALLOWED_HOSTS:
https for public hosts, http for local loopback hosts.
"""
if explicit:
return explicit
local_hosts = {"localhost", "127.0.0.1", "0.0.0.0"}
origins: list[str] = []
for host in allowed_hosts:
if not host or host == "*" or host.startswith("."):
continue
hostname = host.split(":")[0]
scheme = "http" if hostname in local_hosts else "https"
origins.append(f"{scheme}://{host}")
return origins
CSRF_TRUSTED_ORIGINS = build_csrf_trusted_origins(
ALLOWED_HOSTS,
env_list("DJANGO_CSRF_TRUSTED_ORIGINS"),
)
INSTALLED_APPS = [
"public.apps.PublicConfig",
"financial.apps.FinancialConfig",
"planning.apps.PlanningConfig",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"whitenoise.runserver_nostatic",
"django.contrib.staticfiles",
"phonenumber_field",
"django_recaptcha",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "company_site.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
"public.context_processors.tianji_tracking",
"public.context_processors.webmcp_context",
"public.context_processors.financial_access",
],
},
},
]
WSGI_APPLICATION = "company_site.wsgi.application"
DATABASES = database_config()
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = {
"staticfiles": {
"BACKEND": "company_site.storage.TolerantManifestStaticFilesStorage",
},
}
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
RECAPTCHA_PUBLIC_KEY = env("RECAPTCHA_PUBLIC_KEY", "")
RECAPTCHA_PRIVATE_KEY = env("RECAPTCHA_PRIVATE_KEY", "")
EMAIL_HOST = env("EMAIL_HOST", "mail.smtp2go.com")
EMAIL_HOST_USER = env("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "")
EMAIL_PORT = int(env("EMAIL_PORT", "2525"))
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True)
LOGIN_REDIRECT_URL = "/"
LOGOUT_REDIRECT_URL = "/"
TIANJI_TRACKER_URL = env(
"TIANJI_TRACKER_URL",
"https://tianji.aimloperations.com/tracker.js",
)
TIANJI_WEBSITE_ID = env("TIANJI_WEBSITE_ID", "")
@@ -0,0 +1,20 @@
"""Beta/staging settings."""
from .base import * # noqa: F403
from .logging import build_logging_config, logging_level_for_env
DEBUG = env_bool("DJANGO_DEBUG", False) # noqa: F405
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405
if DEBUG:
import warnings
warnings.warn("DEBUG is enabled in beta environment.", stacklevel=1)
# Same reverse-proxy assumptions as production when TLS is terminated upstream.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SESSION_COOKIE_SECURE = not DEBUG
CSRF_COOKIE_SECURE = not DEBUG
LOGGING = build_logging_config(logging_level_for_env("beta"), "beta")
+15
View File
@@ -0,0 +1,15 @@
"""Development settings."""
from .base import * # noqa: F403
from .logging import build_logging_config, logging_level_for_env
DEBUG = True
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", False) # noqa: F405
STORAGES = {
"staticfiles": {
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
},
}
LOGGING = build_logging_config(logging_level_for_env("dev"), "dev")
@@ -0,0 +1,58 @@
"""Environment-specific logging configuration."""
import os
def build_logging_config(level: str, environment: str) -> dict:
"""Return a Django LOGGING dict for the given level and environment name."""
return {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": (
f"{{levelname}} {{asctime}} {{name}} {{process:d}} {{thread:d}} "
f"[env={environment}] {{message}}"
),
"style": "{",
},
"simple": {
"format": f"{{levelname}} [env={environment}] {{message}}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose" if environment == "dev" else "simple",
},
},
"root": {
"handlers": ["console"],
"level": level,
},
"loggers": {
"django": {
"handlers": ["console"],
"level": level,
"propagate": False,
},
"django.request": {
"handlers": ["console"],
"level": "ERROR" if environment == "prod" else level,
"propagate": False,
},
},
}
def logging_level_for_env(environment: str) -> str:
override = os.environ.get("DJANGO_LOG_LEVEL")
if override:
return override.upper()
if environment == "dev":
return "DEBUG"
if environment == "beta":
return "INFO"
return "WARNING"
@@ -0,0 +1,18 @@
"""Production settings."""
from .base import * # noqa: F403
from .logging import build_logging_config, logging_level_for_env
DEBUG = False
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", True) # noqa: F405
if not env("DJANGO_SECRET_KEY"): # noqa: F405
raise ValueError("DJANGO_SECRET_KEY must be set in production.")
# App sits behind a reverse proxy that terminates TLS (docker :8000).
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
USE_X_FORWARDED_HOST = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
LOGGING = build_logging_config(logging_level_for_env("prod"), "prod")
+32
View File
@@ -0,0 +1,32 @@
"""Custom static files storage.
WhiteNoise's manifest storage post-processes JS/CSS during ``collectstatic`` and
strictly resolves every referenced file, including ``sourceMappingURL`` comments
in vendored bundles. Some third-party assets reference ``.map`` files that are
not shipped, which makes ``collectstatic`` fail hard.
``TolerantManifestStaticFilesStorage`` leaves such unresolved references
untouched instead of raising, so a missing source map can't break the build.
"""
from whitenoise.storage import CompressedManifestStaticFilesStorage
class TolerantManifestStaticFilesStorage(CompressedManifestStaticFilesStorage):
# Don't 500 at runtime when a {% static %} reference isn't in the manifest;
# fall back to the plain name (mirrors non-manifest storage behaviour).
manifest_strict = False
def _stored_name(self, name, hashed_files):
"""Tolerate missing references during collectstatic post-processing."""
try:
return super()._stored_name(name, hashed_files)
except ValueError:
return name
def stored_name(self, name):
"""Tolerate missing manifest entries at request time."""
try:
return super().stored_name(name)
except ValueError:
return name
+3
View File
@@ -20,6 +20,9 @@ from django.urls import include, path
urlpatterns = [
path("public/", include("public.urls")),
path("", include("public.urls")),
path("financial/", include("financial.urls")),
path("planning/", include("planning.urls")),
path("accounts/", include("django.contrib.auth.urls")),
path('admin/', admin.site.urls),
]
+56
View File
@@ -0,0 +1,56 @@
# Agentic Browsing Readiness
This site targets Google's experimental **Agentic Browsing** Lighthouse category (v13.3+), which checks whether AI agents can read, navigate, and act on public pages.
PageSpeed Insights does not yet expose this category. Run audits locally:
```bash
npx lighthouse@latest https://aimloperations.com \
--only-categories=agentic-browsing \
--chrome-flags="--enable-experimental-web-platform-features" \
--output=html --output-path=agentic-browsing-report.html
```
Test at minimum:
- `/` (homepage)
- `/contact` (primary conversion page)
## What we ship
| Check | Implementation |
|-------|----------------|
| **llms.txt** | `GET /llms.txt` — machine-readable site summary |
| **robots.txt** | `GET /robots.txt` — crawl rules + sitemap reference |
| **sitemap.xml** | `GET /sitemap.xml` — public marketing URLs |
| **Accessibility tree** | Form labels, semantic nav controls, ARIA on dialogs |
| **Layout stability** | Fixed cookie banner, reserved hero/marquee space, font fallbacks |
| **WebMCP tools** | `navigator.modelContext` tools behind `WEBMCP_ENABLED` (see [webmcp.md](webmcp.md)) |
## WebMCP (issue #9)
When `WEBMCP_ENABLED=True`:
- Navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`) load on all public pages
- Contact page registers `submit_contact_inquiry` via declarative form annotations (`toolname`, `tooldescription`, `toolparamdescription`); reCAPTCHA renders outside the annotated form
- Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment
Full tool catalog, Chrome flag setup, and reCAPTCHA notes: **[docs/webmcp.md](webmcp.md)**
## Regression checklist
Before merging public-facing template or CSS changes:
1. Contact form fields have associated `<label>` elements (not placeholder-only).
2. Nav dropdowns use `<button>` triggers with `aria-expanded` / `aria-haspopup`.
3. Cookie consent banner stays `position: fixed` (no document flow shift).
4. `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200.
5. With `WEBMCP_ENABLED=True`, public pages include `webmcp-config` and `webmcp-tools.js`; contact form always has `toolname`, `tooldescription`, and `toolparamdescription` on fields.
6. Re-run Lighthouse agentic-browsing on homepage and contact page (Chrome experimental flag on).
## References
- [Lighthouse Agentic Browsing audit overview](https://locomotive.agency/blog/lighthouse-agentic-browsing-audit/)
- [llms.txt proposal](https://llmstxt.org/)
- [WebMCP tool catalog](webmcp.md)
- Gitea issue #5 (foundational), issue #9 (WebMCP)
+108
View File
@@ -0,0 +1,108 @@
# WebMCP Tool Catalog
WebMCP exposes public marketing actions as named, callable tools via `navigator.modelContext` (or `document.modelContext` in newer builds). Tools register only when `WEBMCP_ENABLED=True` in Django settings.
## Enablement
### 1. Django setting
```python
# company_site/settings.py (or environment-specific settings)
WEBMCP_ENABLED = True
```
Default is `False` so production stays opt-in until you deliberately enable agent tooling.
### 2. Chrome experimental flag
WebMCP requires the experimental web platform features flag:
1. Open `chrome://flags/#enable-experimental-web-platform-features`
2. Set **Enable experimental web platform features** to **Enabled**
3. Restart Chrome
Serve the site over HTTPS (or `localhost`) — WebMCP requires a secure context.
### 3. Lighthouse audit
```bash
npx lighthouse@latest https://aimloperations.com/contact \
--only-categories=agentic-browsing \
--chrome-flags="--enable-experimental-web-platform-features" \
--output=html --output-path=agentic-browsing-contact.html
npx lighthouse@latest https://aimloperations.com/ \
--only-categories=agentic-browsing \
--chrome-flags="--enable-experimental-web-platform-features" \
--output=html --output-path=agentic-browsing-home.html
```
Use `https://` URLs to avoid redirect warnings. Run with `WEBMCP_ENABLED=True` on the target environment.
## Registered tools
| Tool | Pages | readOnly | Description |
|------|-------|----------|-------------|
| `list_services` | All public pages | Yes | Returns service name, slug, URL, and summary |
| `get_page_content` | All public pages | Yes | Look up a page by slug or display name |
| `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL |
| `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled |
| `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint |
### `submit_contact_inquiry`
**Input schema:**
| Field | Type | Required |
|-------|------|----------|
| `name` | string | Yes |
| `email` | string | Yes |
| `subject` | string | Yes |
| `message` | string | No |
**Output:** JSON with `success: true` and a confirmation message, or `success: false` with an `error` string.
**reCAPTCHA limitation:** In production (`DEBUG=False`), the contact endpoint requires reCAPTCHA v3. Automated agents cannot complete captcha without a real browser session and the reCAPTCHA widget. For Lighthouse demos, run with `DEBUG=True` or use the declarative HTML form (agent fills fields; user submits manually).
### Declarative form annotation
The contact `<form>` declares WebMCP coverage via HTML attributes (always rendered, independent of `WEBMCP_ENABLED`):
```html
<form id="contact-form" toolname="submit_contact_inquiry"
tooldescription="Submit a contact inquiry to AI ML Operations">
<input name="name" toolparamdescription="Full name of the person submitting the inquiry." required>
...
</form>
<!-- reCAPTCHA renders outside the annotated form and links via form="contact-form" -->
```
The imperative `registerTool` on `/contact` adds structured `execute` behavior with success/error responses when `WEBMCP_ENABLED=True`.
## Data source
Service metadata is sourced from `PUBLIC_PAGE_ENTRIES` in `public/seo.py` — the same config that powers `sitemap.xml` and `llms.txt`. Navigation tools always stay in sync with SEO endpoints.
## Security scope
- Tools run in the visitor's browser session (no API keys)
- Only unauthenticated marketing flows are exposed
- Planning, financial, and admin actions are **not** registered
## Manual verification
Use [Chrome Labs Awesome WebMCP](https://github.com/chrome-labs/awesome-webmcp) or DevTools console:
```javascript
// Feature-detect (Chrome with experimental flag)
'modelContext' in navigator && typeof navigator.modelContext.registerTool === 'function'
```
With a WebMCP-capable browser on `/`, call `list_services` from the agent UI. On `/contact`, call `submit_contact_inquiry` with test data (DEBUG mode).
## References
- [WebMCP specification overview](https://specification.website/spec/agent-readiness/webmcp/)
- [Google Chrome modern-web-guidance — agentic JavaScript tools](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/webmcp/agentic-javascript-tools.md)
- [Awesome WebMCP demos](https://github.com/chrome-labs/awesome-webmcp)
- Gitea issue #9
Regular → Executable
+22 -2
View File
@@ -1,8 +1,28 @@
from django.contrib import admin
from .models import Contract
# Register your models here.
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile
class ContractAdmin(admin.ModelAdmin):
pass
class EmployeeAdmin(admin.ModelAdmin):
pass
class UserProfileAdmin(admin.ModelAdmin):
list_display = ("user", "user_type")
list_filter = ("user_type",)
class ChargeNumberAdmin(admin.ModelAdmin):
pass
class TimeCardAdmin(admin.ModelAdmin):
pass
class TimeCardCellAdmin(admin.ModelAdmin):
pass
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)
+3
View File
@@ -4,3 +4,6 @@ from django.apps import AppConfig
class FinancialConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'financial'
def ready(self):
import financial.signals # noqa: F401
+87 -6
View File
@@ -1,20 +1,101 @@
import datetime
from django import forms
from django.contrib.auth.models import User
from django.forms import ModelForm
from .models import Employee, Contract, ChargeNumber
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")
last_name = forms.CharField(max_length=30, required=False, label="Last Name")
address_1 = forms.CharField(max_length=128, label="Address Line 1")
address_2 = forms.CharField(max_length=128, required=False, label="Address Line 2")
city = forms.CharField(max_length=64, label="City")
state = forms.CharField(max_length=2, label="State (e.g. NY)")
zip_code = forms.CharField(max_length=5, label="ZIP Code")
class Meta:
model = Employee
fields = ["user", "manager", "phoneNumber", "slary"]
def save(self, commit=True):
employee = super().save(commit=False)
if self.cleaned_data.get('first_name') or self.cleaned_data.get('last_name'):
if self.cleaned_data.get('first_name'):
employee.user.first_name = self.cleaned_data.get('first_name')
if self.cleaned_data.get('last_name'):
employee.user.last_name = self.cleaned_data.get('last_name')
employee.user.save()
address = AddressModel.objects.create(
address_1=self.cleaned_data['address_1'],
address_2=self.cleaned_data['address_2'],
city=self.cleaned_data['city'],
state=self.cleaned_data['state'],
zip_code=self.cleaned_data['zip_code'],
)
employee.primaryAddress = address
employee.workAddress = address
if commit:
employee.save()
set_user_type(employee.user, UserProfile.UserType.EMPLOYEE)
return employee
class EmployeeForm(ModelForm):
class Meta:
model = Employee
# TODO: fix slary to be salary
fields = ["primaryAddress","workAddress", "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 Meta:
model = Contract
fields = ["contract_type","name","proposed_amount","baseline_amount","funded_amount","baseline_start","baseline_end"]
fields = ["contract_type","name","proposed_amount","baseline_amount","funded_amount","budget_hours","baseline_start","baseline_end"]
class ChargeNumberForm(ModelForm):
class Meta:
model = ChargeNumber
fields = ["charge_number_type","amount", "start_date","end_date"]
fields = ["charge_number_type","amount", "budget_hours", "percent_complete", "start_date","end_date"]
class TimeLogForm(ModelForm):
start_time = forms.TimeField(required=False, widget=forms.TimeInput(attrs={'type': 'time'}))
end_time = forms.TimeField(required=False, widget=forms.TimeInput(attrs={'type': 'time'}))
hour = forms.FloatField(required=False, label="Duration (hours)")
date = forms.DateField(initial=datetime.date.today, widget=forms.DateInput(attrs={'type': 'date'}))
class Meta:
model = TimeCardCell
fields = ["charge_number", "date", "start_time", "end_time", "hour"]
def clean(self):
cleaned_data = super().clean()
start = cleaned_data.get('start_time')
end = cleaned_data.get('end_time')
duration = cleaned_data.get('hour')
if start and end:
dt_start = datetime.datetime.combine(datetime.date.today(), start)
dt_end = datetime.datetime.combine(datetime.date.today(), end)
diff = (dt_end - dt_start).total_seconds() / 3600.0
if diff < 0:
diff += 24.0
cleaned_data['hour'] = round(diff, 2)
elif start and duration:
dt_start = datetime.datetime.combine(datetime.date.today(), start)
dt_end = dt_start + datetime.timedelta(hours=duration)
cleaned_data['end_time'] = (dt_end).time()
elif not duration and not (start and end):
raise forms.ValidationError("You must provide either (Start Time and End Time) OR (Start Time and Duration) OR (Duration).")
if not cleaned_data.get('hour') and duration:
cleaned_data['hour'] = duration
return cleaned_data
@@ -0,0 +1,76 @@
# Generated by Django 5.0 on 2026-03-20 11:40
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0005_chargenumber_financial_chargenumber_charge_number_type_chargenumbertypeenum_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='chargenumber',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='chargenumber',
name='last_modified_BY',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='contract',
name='budget_hours',
field=models.FloatField(default=0.0),
),
migrations.AddField(
model_name='contract',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='contract',
name='last_modified_BY',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='employee',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='employee',
name='last_modified_BY',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='timecard',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='timecard',
name='last_modified_BY',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='timecardcell',
name='contract',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='financial.contract'),
),
migrations.AddField(
model_name='timecardcell',
name='created_by',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='timecardcell',
name='last_modified_BY',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,34 @@
# Generated by Django 5.0 on 2026-03-20 14:35
import django_enum.fields
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0006_chargenumber_created_by_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveConstraint(
model_name='contract',
name='financial_Contract_contract_type_ContractTypeEnum',
),
migrations.AddField(
model_name='contract',
name='award_amount',
field=models.FloatField(default=0.0),
),
migrations.AlterField(
model_name='contract',
name='contract_type',
field=django_enum.fields.EnumCharField(choices=[('FFP', 'FIRM_FIX_PRICED'), ('CPFF', 'COST_PLUS_FIXED_FEE'), ('MAX', 'MAX_NUM_CONTRACT_TYPES')], max_length=4),
),
migrations.AddConstraint(
model_name='contract',
constraint=models.CheckConstraint(check=models.Q(('contract_type__in', ['FFP', 'CPFF', 'MAX'])), name='financial_Contract_contract_type_ContractTypeEnum'),
),
]
@@ -0,0 +1,38 @@
# Generated by Django 5.0 on 2026-03-20 14:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0007_remove_contract_financial_contract_contract_type_contracttypeenum_and_more'),
]
operations = [
migrations.AlterField(
model_name='chargenumber',
name='slug',
field=models.SlugField(blank=True, null=True, unique=True),
),
migrations.AlterField(
model_name='contract',
name='slug',
field=models.SlugField(blank=True, null=True, unique=True),
),
migrations.AlterField(
model_name='employee',
name='slug',
field=models.SlugField(blank=True, null=True, unique=True),
),
migrations.AlterField(
model_name='timecard',
name='slug',
field=models.SlugField(blank=True, null=True, unique=True),
),
migrations.AlterField(
model_name='timecardcell',
name='slug',
field=models.SlugField(blank=True, null=True, unique=True),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.0 on 2026-03-20 15:18
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0008_alter_chargenumber_slug_alter_contract_slug_and_more'),
]
operations = [
migrations.AddField(
model_name='timecardcell',
name='end_time',
field=models.TimeField(blank=True, null=True),
),
migrations.AddField(
model_name='timecardcell',
name='start_time',
field=models.TimeField(blank=True, null=True),
),
]
@@ -0,0 +1,25 @@
# Generated by Django 5.0 on 2026-03-20 15:30
import django.db.models.deletion
import phonenumber_field.modelfields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0009_timecardcell_end_time_timecardcell_start_time'),
]
operations = [
migrations.AlterField(
model_name='employee',
name='manager',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='manager_employee', to='financial.employee'),
),
migrations.AlterField(
model_name='employee',
name='phoneNumber',
field=phonenumber_field.modelfields.PhoneNumberField(blank=True, max_length=128, null=True, region=None, unique=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0 on 2026-03-23 07:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0010_alter_employee_manager_alter_employee_phonenumber'),
]
operations = [
migrations.AddField(
model_name='chargenumber',
name='budget_hours',
field=models.FloatField(default=0.0),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 5.0 on 2026-03-23 07:43
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0011_chargenumber_budget_hours'),
]
operations = [
migrations.RemoveField(
model_name='timecardcell',
name='contract',
),
migrations.AddField(
model_name='timecardcell',
name='charge_number',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='financial.chargenumber'),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.0 on 2026-03-23 08:22
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0012_remove_timecardcell_contract_and_more'),
]
operations = [
migrations.AlterField(
model_name='employee',
name='primaryAddress',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='primary_address_employee', to='financial.addressmodel'),
),
migrations.AlterField(
model_name='employee',
name='workAddress',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='work_address_employee', to='financial.addressmodel'),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.0 on 2026-03-26 14:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0013_alter_employee_primaryaddress_and_more'),
]
operations = [
migrations.AddField(
model_name='chargenumber',
name='name',
field=models.CharField(blank=True, max_length=100, null=True),
),
]
@@ -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),
]
Regular → Executable
+236 -10
View File
@@ -4,27 +4,40 @@ from phonenumber_field.modelfields import PhoneNumberField
from django.utils import timezone
from django_enum import EnumField
from django.utils.text import slugify
from django.db.models import Sum
import datetime
from dateutil.relativedelta import relativedelta
def user_str(self):
if self.first_name and self.last_name:
return f"{self.first_name} {self.last_name}"
if self.username:
return self.username
if self.email:
return self.email
return str(self.id)
User.__str__ = user_str
# Abstract Model Classes
class TimeMixin(models.Model):
created = models.DateTimeField(default=timezone.now)
last_modified = models.DateTimeField(default=timezone.now)
created_by = models.ForeignKey
last_modified_BY = models.ForeignKey
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='+')
last_modified_BY = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='+')
class Meta:
abstract = True
class IdMixin(models.Model):
slug = models.SlugField()
slug = models.SlugField(blank=True, unique=True, null=True)
class Meta:
abstract = True
def save(self, *args, **kwargs):
if self.slug is None:
if not self.slug:
self.slug = slugify(datetime.datetime.now().time())
super(IdMixin, self).save(*args, **kwargs)
@@ -32,6 +45,7 @@ class IdMixin(models.Model):
class Contract(IdMixin, TimeMixin):
class ContractTypeEnum(models.TextChoices):
FIRM_FIX_PRICED = "FFP", "FIRM_FIX_PRICED"
COST_PLUS_FIXED_FEE = "CPFF", "COST_PLUS_FIXED_FEE"
MAX_NUM_CONRACT_TYPES = "MAX", "MAX_NUM_CONTRACT_TYPES"
contract_type = EnumField(ContractTypeEnum)
@@ -40,12 +54,134 @@ class Contract(IdMixin, TimeMixin):
proposed_amount = models.FloatField(default=0.0)
baseline_amount = models.FloatField(default=0.0)
funded_amount = models.FloatField(default=0.0)
award_amount = models.FloatField(default=0.0)
budget_hours = models.FloatField(default=0.0)
baseline_start = models.DateField(null=True, blank=True, default=None)
baseline_end = models.DateField(null=True, blank=True, default=None)
linkedContracts = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True)
# TODO: make calc ev func
def __str__(self):
return f"{self.name} - {self.contract_type}"
@property
def ticket_percent_complete(self):
charge_numbers = self.chargenumber_set.all()
total_hours = sum(cn.budget_hours for cn in charge_numbers)
if total_hours == 0:
return 0.0
weighted_sum = sum(cn.budget_hours * cn.get_percent_complete for cn in charge_numbers)
return weighted_sum / total_hours
def get_evm_data(self):
"""Compute all Earned Value Management metrics for this contract."""
today = timezone.now().date()
# --- Budget at Completion (BAC) ---
bac_hours = self.budget_hours or 0.0
bac_dollars = self.baseline_amount if self.baseline_amount > 0 else (self.funded_amount or 0.0)
# --- Time fraction elapsed ---
if self.baseline_start and self.baseline_end and self.baseline_end > self.baseline_start:
total_days = (self.baseline_end - self.baseline_start).days
elapsed_days = max(0, (min(today, self.baseline_end) - self.baseline_start).days)
time_fraction = min(1.0, elapsed_days / total_days) if total_days > 0 else 0.0
else:
total_days = 0
elapsed_days = 0
time_fraction = 0.0
# --- Planned Value (PV) ---
pv_hours = bac_hours * time_fraction
pv_dollars = bac_dollars * time_fraction
# --- Earned Value (EV) ---
percent_complete = self.ticket_percent_complete # 0-100
ev_hours = bac_hours * (percent_complete / 100.0)
ev_dollars = bac_dollars * (percent_complete / 100.0)
# --- Actual Cost (AC) ---
from financial.models import TimeCardCell # avoid circular at module level
cells = TimeCardCell.objects.filter(
charge_number__contract=self
).select_related('timeCard__employee')
ac_hours = cells.aggregate(total=Sum('hour'))['total'] or 0.0
ac_dollars = sum(
(cell.hour or 0.0) * (cell.timeCard.employee.hourly_salary if cell.timeCard and cell.timeCard.employee else 0.0)
for cell in cells
)
# --- Variances ---
sv_hours = ev_hours - pv_hours
sv_dollars = ev_dollars - pv_dollars
cv_hours = ev_hours - ac_hours
cv_dollars = ev_dollars - ac_dollars
# --- Performance Indices ---
spi = (ev_dollars / pv_dollars) if pv_dollars > 0 else 0.0
cpi = (ev_dollars / ac_dollars) if ac_dollars > 0 else 0.0
spi_hours = (ev_hours / pv_hours) if pv_hours > 0 else 0.0
cpi_hours = (ev_hours / ac_hours) if ac_hours > 0 else 0.0
# --- Monthly Time-Series for S-Curve ---
time_series = []
if self.baseline_start and self.baseline_end and total_days > 0:
# Build cumulative AC by month
ac_by_month = {}
for cell in cells:
if cell.date:
key = cell.date.strftime('%Y-%m')
cost = (cell.hour or 0.0) * (
cell.timeCard.employee.hourly_salary if cell.timeCard and cell.timeCard.employee else 0.0
)
ac_by_month[key] = ac_by_month.get(key, 0.0) + cost
cursor = self.baseline_start.replace(day=1)
end_limit = max(self.baseline_end, today)
cumulative_ac = 0.0
while cursor <= end_limit:
month_key = cursor.strftime('%Y-%m')
# PV at this point in time
days_into = max(0, (cursor - self.baseline_start).days)
pv_at = bac_dollars * min(1.0, days_into / total_days)
# Cumulative AC
cumulative_ac += ac_by_month.get(month_key, 0.0)
# EV: proportional to current % complete, scaled by time
if cursor <= today:
ev_at = ev_dollars * min(1.0, days_into / max(1, (today - self.baseline_start).days)) if (today - self.baseline_start).days > 0 else 0.0
else:
ev_at = ev_dollars # flat after today
time_series.append({
'month': month_key,
'pv': round(pv_at, 2),
'ev': round(ev_at, 2),
'ac': round(cumulative_ac, 2),
})
cursor += relativedelta(months=1)
return {
'bac_hours': round(bac_hours, 2),
'bac_dollars': round(bac_dollars, 2),
'pv_hours': round(pv_hours, 2),
'pv_dollars': round(pv_dollars, 2),
'ev_hours': round(ev_hours, 2),
'ev_dollars': round(ev_dollars, 2),
'ac_hours': round(ac_hours, 2),
'ac_dollars': round(ac_dollars, 2),
'sv_hours': round(sv_hours, 2),
'sv_dollars': round(sv_dollars, 2),
'cv_hours': round(cv_hours, 2),
'cv_dollars': round(cv_dollars, 2),
'spi': round(spi, 2),
'cpi': round(cpi, 2),
'spi_hours': round(spi_hours, 2),
'cpi_hours': round(cpi_hours, 2),
'percent_complete': round(percent_complete, 2),
'time_series': time_series,
}
class ChargeNumber(IdMixin, TimeMixin):
class ChargeNumberTypeEnum(models.TextChoices):
@@ -56,14 +192,49 @@ class ChargeNumber(IdMixin, TimeMixin):
MAX_NUM_TASK_TYPES = "MAX", "MAX_NUM_TASK_TYPES"
charge_number_type = EnumField(ChargeNumberTypeEnum)
name = models.CharField(max_length=100, blank=True, null=True)
contract = models.ForeignKey(Contract, on_delete=models.CASCADE)
amount = models.FloatField(default=0.0)
budget_hours = models.FloatField(default=0.0)
percent_complete = models.FloatField(default=0.0)
# TODO: add validator to make sure the range is 0.0 - 100
start_date = models.DateField(null=True, blank=True, default = None)
end_date = models.DateField(null=True, blank=True, default = None)
def __str__(self):
return self.name or self.slug or f"CN-{self.id}"
@property
def get_percent_complete(self):
if self.charge_number_type == self.ChargeNumberTypeEnum.QBD:
total = self.tickets.count()
if total == 0:
return 0.0
done = self.tickets.filter(status='DONE').count()
return (done / total) * 100.0
return self.percent_complete
def clean(self):
from django.core.exceptions import ValidationError
super().clean()
if self.charge_number_type == "0_100":
if self.percent_complete not in [0.0, 100.0]:
raise ValidationError({"percent_complete": "For ZERO_ONE_HUNDRED, percent complete must be 0 or 100."})
if self.start_date and self.end_date:
delta = self.end_date - self.start_date
if delta.days > 31:
raise ValidationError({"end_date": "Duration cannot be more than a month for ZERO_ONE_HUNDRED."})
if self.charge_number_type == "50_50":
if self.percent_complete not in [0.0, 50.0, 100.0]:
raise ValidationError({"percent_complete": "For FIFTY_FIFTY, percent complete must be 0, 50, or 100."})
if self.start_date and self.end_date:
delta = self.end_date - self.start_date
if delta.days > 62:
raise ValidationError({"end_date": "Duration cannot be more than two months for FIFTY_FIFTY."})
class AddressModel(models.Model):
address_1 = models.CharField(max_length=128)
address_2 = models.CharField(max_length=128, blank=True)
@@ -72,15 +243,43 @@ 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")
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
user = models.OneToOneField(User, on_delete=models.CASCADE)
primaryAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="primary_address_employee")
workAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="work_address_employee")
phoneNumber = PhoneNumberField(null=False, blank=False, unique=True)
primaryAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="primary_address_employee", null=True, blank=True)
workAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="work_address_employee", null=True, blank=True)
phoneNumber = PhoneNumberField(null=True, blank=True, unique=True)
slary= models.FloatField(default=0.0)
# TODO: get hourly salary
def __str__(self):
return str(self.user)
@property
def hourly_salary(self):
return (self.slary or 0.0) / 2040.0
# TODO: roles, jpbTitles
@@ -97,9 +296,36 @@ class TimeCard(IdMixin, TimeMixin):
class TimeCardCell(IdMixin, TimeMixin):
timeCard = models.ForeignKey(TimeCard, on_delete=models.CASCADE)
date = models.DateField(null=True, default = None)
start_time = models.TimeField(null=True, blank=True)
end_time = models.TimeField(null=True, blank=True)
hour = models.FloatField(default = 0.0)
charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True)
def set_user_type(user, user_type):
"""Set user type and sync the Employee record (mutually exclusive types)."""
user.__dict__.pop("profile", None)
profile, _ = UserProfile.objects.get_or_create(
user=user,
defaults={"user_type": user_type},
)
if profile.user_type == user_type:
if user_type == UserProfile.UserType.EMPLOYEE:
Employee.objects.get_or_create(user=user)
return profile
if user_type == UserProfile.UserType.CLIENT:
employee = Employee.objects.filter(user=user).first()
if employee and TimeCardCell.objects.filter(timeCard__employee=employee).exists():
raise ValueError("Cannot set Client: user has time log entries.")
profile.user_type = user_type
profile.save()
user.__dict__.pop("profile", None)
if user_type == UserProfile.UserType.EMPLOYEE:
Employee.objects.get_or_create(user=user)
else:
Employee.objects.filter(user=user).delete()
return profile
+76
View File
@@ -0,0 +1,76 @@
from functools import wraps
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
def get_user_profile(user):
if not user.is_authenticated:
return None
from .models import UserProfile
return UserProfile.objects.filter(user_id=user.pk).first()
def is_financial_admin(user):
return user.is_active and user.is_superuser
def is_employee_user(user):
profile = get_user_profile(user)
return bool(profile and profile.is_employee())
def is_client_user(user):
profile = get_user_profile(user)
return bool(profile and profile.is_client())
def has_financial_access(user):
return is_financial_admin(user) or is_employee_user(user) or is_client_user(user)
def can_write_financials(user):
return is_financial_admin(user) or is_employee_user(user)
def get_employees():
from .models import Employee, UserProfile
return Employee.objects.filter(
user__profile__user_type=UserProfile.UserType.EMPLOYEE
)
def get_user_employee(user):
from .models import Employee
if not is_employee_user(user):
return None
return Employee.objects.filter(user=user).first()
def financial_admin_required(view_func):
return user_passes_test(is_financial_admin)(view_func)
def financial_access_required(view_func):
@login_required
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
if has_financial_access(request.user):
return view_func(request, *args, **kwargs)
raise PermissionDenied
return _wrapped
def financial_write_required(view_func):
@login_required
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
if can_write_financials(request.user):
return view_func(request, *args, **kwargs)
raise PermissionDenied
return _wrapped
+15
View File
@@ -0,0 +1,15 @@
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_profile_for_user(sender, instance, created, **kwargs):
"""Auto-create a UserProfile (default Client) whenever a User is created."""
if created:
UserProfile.objects.get_or_create(
user=instance,
defaults={"user_type": UserProfile.UserType.CLIENT},
)
@@ -1,55 +1,472 @@
<!-- a lot of stuff-->
<!doctype html>
{% extends "base.html" %}
{% load static %}
<html lang="en"">
<head>
<title>Contract Detail</title>
<link href="{% static 'financial/css/material-dashboard.css' %}" rel="stylesheet" >
<link rel="icon" type="image/xicon" href="{% static 'public/img/logo.png' %}">
<body class="g-sidenav-show bg-gray-200" >
<main class="main-content position-relative max-height-vh-100 h-100 border-radius-lg">
{% block title %}Contract Detail - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<div style="margin-bottom: 2rem;">
<a href="{% url 'contracts' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Back to
Contracts</a>
</div>
{% if is_new %}
<h1>New Contract</h1>
<form method="post" action='{% url "new_contract" %}' >
<h1 class="section-title" style="text-align: left;">New Contract</h1>
<div class="card" style="max-width: 600px;">
<form method="post" action='{% url "new_contract" %}'>
{% csrf_token %}
{{ form }}
<button type=""button" value="submit">Create</button>
{{ form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Create</button>
</form>
</div>
{% else %}
<h1> {{ contract.name }}</h1>
<form method="post" action='{% url "contract_detail" contract.slug %}' >
<h1 class="section-title" style="text-align: left;">{{ contract.name }}</h1>
<div class="card" style="max-width: 600px;">
<form method="post" action='{% url "contract_detail" contract.slug %}'>
{% csrf_token %}
{{ form }}
<button type=""button" value="submit">Update</button>
{{ form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Update</button>
</form>
</div>
{% endif %}
<hr style="margin: 40px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
<!-- ═══════════════════════════════════════════════════════ -->
<!-- EARNED VALUE MANAGEMENT SECTION -->
<!-- ═══════════════════════════════════════════════════════ -->
{% if evm %}
<h2 class="section-title" style="text-align: left; font-size: 2rem;">Earned Value Management</h2>
<!-- EVM KPI Cards -->
<div
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 16px; margin-bottom: 2rem;">
<!-- BAC -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">BAC (Budget)</span>
<span class="evm-kpi-value">${{ evm.bac_dollars|floatformat:2 }}</span>
<span class="evm-kpi-sub">{{ evm.bac_hours }} hrs</span>
</div>
<!-- PV -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">PV (Planned)</span>
<span class="evm-kpi-value">${{ evm.pv_dollars|floatformat:2 }}</span>
<span class="evm-kpi-sub">{{ evm.pv_hours }} hrs</span>
</div>
<!-- EV -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">EV (Earned)</span>
<span class="evm-kpi-value" style="color: #39ff14;">${{ evm.ev_dollars|floatformat:2 }}</span>
<span class="evm-kpi-sub">{{ evm.ev_hours }} hrs · {{ evm.percent_complete }}% done</span>
</div>
<!-- AC -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">AC (Actual Cost)</span>
<span class="evm-kpi-value" style="color: #bc13fe;">${{ evm.ac_dollars|floatformat:2 }}</span>
<span class="evm-kpi-sub">{{ evm.ac_hours }} hrs spent</span>
</div>
</div>
<!-- Variance & Index Cards -->
<div
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 16px; margin-bottom: 2rem;">
<!-- SV -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">Schedule Variance</span>
<span class="evm-kpi-value {% if evm.sv_dollars >= 0 %}evm-positive{% else %}evm-negative{% endif %}">
${{ evm.sv_dollars|floatformat:2 }}
</span>
<span class="evm-kpi-sub">{% if evm.sv_dollars >= 0 %}Ahead{% else %}Behind{% endif %} schedule</span>
</div>
<!-- CV -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">Cost Variance</span>
<span class="evm-kpi-value {% if evm.cv_dollars >= 0 %}evm-positive{% else %}evm-negative{% endif %}">
${{ evm.cv_dollars|floatformat:2 }}
</span>
<span class="evm-kpi-sub">{% if evm.cv_dollars >= 0 %}Under{% else %}Over{% endif %} budget</span>
</div>
<!-- SPI -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">SPI</span>
<span
class="evm-kpi-value {% if evm.spi >= 1 %}evm-positive{% elif evm.spi > 0 %}evm-negative{% endif %}">
{{ evm.spi }}
</span>
<span class="evm-kpi-sub">{% if evm.spi >= 1 %}On/ahead{% elif evm.spi > 0 %}Behind{% else %}No data{%
endif %}</span>
</div>
<!-- CPI -->
<div class="card evm-kpi-card">
<span class="evm-kpi-label">CPI</span>
<span
class="evm-kpi-value {% if evm.cpi >= 1 %}evm-positive{% elif evm.cpi > 0 %}evm-negative{% endif %}">
{{ evm.cpi }}
</span>
<span class="evm-kpi-sub">{% if evm.cpi >= 1 %}Efficient{% elif evm.cpi > 0 %}Over-spending{% else %}No
data{% endif %}</span>
</div>
</div>
<!-- Charts: full-width rows -->
<div class="evm-charts-stack">
<div class="card evm-chart-card">
<h3 class="evm-chart-title">S-Curve (PV vs EV vs AC)</h3>
<div class="evm-chart-canvas-wrap">
<canvas id="evmSCurveChart"></canvas>
</div>
</div>
<div class="card evm-chart-card">
<h3 class="evm-chart-title">Performance Indices</h3>
<div class="evm-chart-canvas-wrap evm-chart-canvas-wrap--compact">
<canvas id="evmIndexChart"></canvas>
</div>
</div>
</div>
{% endif %}
<hr style="margin: 40px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
{% if is_new %}
{% else %}
<h2> Charge Numbers</h2>
{% if charge_numbes %}
<p> put charge number table here</p>
<p> Create a new charge number</p>
{{ charge_number_form }}
{% else %}
<p> There are no charge numbers for this contract</p>
<form method="post" action='{% url "new_charge_number" %}' >
<h2 class="section-title" style="text-align: left; font-size: 2rem;">Charge Numbers</h2>
{% if charge_numbers %}
{% if mermaid_gantt %}
<div class="card charge-gantt-card" style="margin-bottom: 2rem; background: var(--surface-color);">
<h3 class="evm-chart-title" style="margin-bottom: 1rem;">Timeline</h3>
<div class="charge-gantt-scroll">
<div class="mermaid">
{{ mermaid_gantt|safe }}
</div>
</div>
</div>
{% endif %}
<div class="card" style="margin-bottom: 2rem;">
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Slug/ID/Name</th>
<th>Type</th>
<th>Amount</th>
<th>% Complete</th>
<th>Start Date</th>
<th>End Date</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for cn in charge_numbers %}
<tr>
<td>{{ cn }}</td>
<td>{{ cn.get_charge_number_type_display }}</td>
<td>${{ cn.amount|floatformat:2 }}</td>
<td>
<span class="badge"
style="background: rgba(0, 243, 255, 0.1); border: 1px solid var(--primary-color); color: var(--primary-color);">
{{ cn.percent_complete|floatformat:0 }}%
</span>
</td>
<td class="charge-date-cell">{{ cn.start_date|default:"-" }}</td>
<td class="charge-date-cell">{{ cn.end_date|default:"-" }}</td>
<td>
<a href="{% url 'update_charge_number' cn.slug %}" class="text-cyber-cyan"
style="font-size: 0.9rem;">Edit</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="card" style="max-width: 600px;">
<h3 style="margin-bottom: 1rem;">Create a new charge number</h3>
<form method="post" action='{% url "new_charge_number" contract.slug %}'>
{% csrf_token %}
{{ charge_number_form }}
<button type=""button" value="submit">Update</button>
{{ charge_number_form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Add Charge Number</button>
</form>
{% endif %}
</div>
{% else %}
<div class="card" style="max-width: 600px;">
<p style="color: var(--text-muted); margin-bottom: 1.5rem;">There are no charge numbers for this contract.
</p>
<form method="post" action='{% url "new_charge_number" contract.slug %}'>
{% csrf_token %}
{{ charge_number_form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Add Charge Number</button>
</form>
</div>
{% endif %}
{% endif %}
</div>
</div>
</body>
<style>
.evm-kpi-card {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 16px;
text-align: center;
background: var(--surface-color);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.evm-kpi-card:hover {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 243, 255, 0.08);
}
.evm-kpi-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-muted, #888);
margin-bottom: 6px;
font-weight: 600;
}
.evm-kpi-value {
font-size: 1.5rem;
font-weight: 800;
color: #fff;
line-height: 1.2;
}
.evm-kpi-sub {
font-size: 0.75rem;
color: var(--text-muted, #888);
margin-top: 4px;
}
.evm-positive {
color: #39ff14 !important;
}
.evm-negative {
color: #ff4444 !important;
}
.evm-charts-stack {
display: flex;
flex-direction: column;
gap: 20px;
margin-bottom: 2rem;
width: 100%;
}
.evm-chart-card {
padding: 20px;
width: 100%;
}
.evm-chart-title {
margin-bottom: 1rem;
font-size: 1.1rem;
color: var(--text-muted);
}
.evm-chart-canvas-wrap {
position: relative;
width: 100%;
height: 320px;
}
.evm-chart-canvas-wrap--compact {
height: 260px;
max-width: 480px;
}
.charge-gantt-scroll {
overflow-x: auto;
width: 100%;
padding-bottom: 0.5rem;
}
.charge-gantt-scroll .mermaid {
min-width: 720px;
}
.charge-date-cell {
white-space: nowrap;
}
</style>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true, theme: 'dark' });
</script>
{% if evm_chart_json %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const raw = '{{ evm_chart_json|escapejs }}';
if (!raw) return;
const data = JSON.parse(raw);
Chart.defaults.color = '#e0e0e0';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
/* ─────── S-Curve Chart ─────── */
const sCurveCanvas = document.getElementById('evmSCurveChart');
if (sCurveCanvas && data.time_series && data.time_series.length > 0) {
const labels = data.time_series.map(d => d.month);
const pvData = data.time_series.map(d => d.pv);
const evData = data.time_series.map(d => d.ev);
const acData = data.time_series.map(d => d.ac);
new Chart(sCurveCanvas.getContext('2d'), {
type: 'line',
data: {
labels: labels,
datasets: [
{
label: 'Planned Value (PV)',
data: pvData,
borderColor: '#00f3ff',
backgroundColor: 'rgba(0, 243, 255, 0.08)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#00f3ff',
},
{
label: 'Earned Value (EV)',
data: evData,
borderColor: '#39ff14',
backgroundColor: 'rgba(57, 255, 20, 0.08)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#39ff14',
},
{
label: 'Actual Cost (AC)',
data: acData,
borderColor: '#bc13fe',
backgroundColor: 'rgba(188, 19, 254, 0.08)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 3,
pointBackgroundColor: '#bc13fe',
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: {
legend: { labels: { color: '#e0e0e0', usePointStyle: true, padding: 16 } },
tooltip: {
callbacks: {
label: ctx => `${ctx.dataset.label}: $${ctx.parsed.y.toLocaleString()}`
}
}
},
scales: {
x: {
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#a0a0a0' }
},
y: {
beginAtZero: true,
title: { display: true, text: 'Dollars ($)', color: '#a0a0a0' },
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: {
color: '#a0a0a0',
callback: v => '$' + v.toLocaleString()
}
}
}
}
});
}
/* ─────── SPI / CPI Bar Chart ─────── */
const indexCanvas = document.getElementById('evmIndexChart');
if (indexCanvas) {
const spi = data.spi || 0;
const cpi = data.cpi || 0;
new Chart(indexCanvas.getContext('2d'), {
type: 'bar',
data: {
labels: ['SPI', 'CPI'],
datasets: [{
label: 'Performance Index',
data: [spi, cpi],
backgroundColor: [
spi >= 1 ? 'rgba(57, 255, 20, 0.6)' : 'rgba(255, 68, 68, 0.6)',
cpi >= 1 ? 'rgba(57, 255, 20, 0.6)' : 'rgba(255, 68, 68, 0.6)',
],
borderColor: [
spi >= 1 ? '#39ff14' : '#ff4444',
cpi >= 1 ? '#39ff14' : '#ff4444',
],
borderWidth: 2,
borderRadius: 6,
barPercentage: 0.5,
}]
},
plugins: [{
id: 'baselineLine',
afterDraw(chart) {
const yScale = chart.scales.y;
const ctx = chart.ctx;
const yPixel = yScale.getPixelForValue(1);
ctx.save();
ctx.strokeStyle = '#00f3ff';
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.beginPath();
ctx.moveTo(chart.chartArea.left, yPixel);
ctx.lineTo(chart.chartArea.right, yPixel);
ctx.stroke();
// Label
ctx.fillStyle = '#00f3ff';
ctx.font = '11px Inter, sans-serif';
ctx.fillText('Target = 1.0', chart.chartArea.right - 72, yPixel - 6);
ctx.restore();
}
}],
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: ctx => `${ctx.label}: ${ctx.parsed.y.toFixed(2)}`
}
}
},
scales: {
x: {
grid: { display: false },
ticks: { color: '#e0e0e0', font: { size: 14, weight: 'bold' } }
},
y: {
beginAtZero: true,
suggestedMax: Math.max(spi, cpi, 1.5) + 0.3,
grid: { color: 'rgba(255,255,255,0.05)' },
ticks: { color: '#a0a0a0' }
}
}
}
});
}
});
</script>
{% endif %}
{% endblock %}
@@ -1,49 +1,157 @@
<!-- a lot of stuff-->
<!doctype html>
{% extends "base.html" %}
{% load static %}
<html lang="en"">
<head>
<title>Profile</title>
<link href="{% static 'financial/css/material-dashboard.css' %}" rel="stylesheet" >
<link rel="icon" type="image/xicon" href="{% static 'public/img/logo.png' %}">
<body class="g-sidenav-show bg-gray-200" >
<main class="main-content position-relative max-height-vh-100 h-100 border-radius-lg">
{% block title %}Contracts - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h1 class="section-title" style="margin-bottom: 0;">Contracts</h1>
<a href="{% url 'financial_index' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Back to
Dashboard</a>
</div>
<h1>Contracts</h1>
{% if chart_data_json %}
<h2 class="section-title" style="font-size: 2rem; margin-bottom: 2rem;">Burn Rate Charts</h2>
<div style="display: flex; flex-wrap: wrap; gap: 20px; margin-bottom: 3rem;">
{% for c in contracts %}
<div
style="flex: 1; min-width: 300px; max-width: 400px; background: var(--surface-color); border: 1px solid rgba(255,255,255,0.1); padding: 15px; border-radius: 8px;">
<canvas id="chart_{{ forloop.counter }}"></canvas>
</div>
{% endfor %}
</div>
{% endif %}
<div class="table-responsive">
{% if contracts %}
<table>
<table class="table" style="margin-bottom: 2rem;">
<thead>
<tr>
<th>Name</th>
<th>Identifier</th>
<th>Type</th>
<th>% Complete</th>
<th>Start Date</th>
<th>End Date</th>
<th>Proposed Amount</th>
<th>Baseline Amount</th>
<th>Funded Amount</th>
<th>Budget Hours</th>
<th>Hours Charged</th>
<th>Money Spent</th>
<th>Money Remaining</th>
<th>Projected End Date</th>
</tr>
</thead>
<tbody>
{% for contract in contracts %}
<tr>
<td> <a href="{% url 'contract_detail' contract.slug %}">{{ contract.name }}</a></td>
<td> {{ contract.slug }}</td>
<td> {{ contract.contract_type }}</td>
<td> {{ contract.baseline_start }}</td>
<td> {{ contract.baseline_end }}</td>
<td> {{ contract.proposed_amount }}</td>
<td> {{ contract.baseline_amount }}</td>
<td> {{ contract.funded_amount }}</td>
<td><a href="{% url 'contract_detail' contract.slug %}">{{ contract.name }}</a></td>
<td>{{ contract.slug }}</td>
<td>{{ contract.contract_type }}</td>
<td>
<span class="badge" style="background: rgba(0, 243, 255, 0.1); border: 1px solid var(--primary-color); color: var(--primary-color);">
{{ contract.ticket_percent_complete|floatformat:0 }}%
</span>
</td>
<td>{{ contract.baseline_start }}</td>
<td>{{ contract.baseline_end }}</td>
<td>{{ contract.proposed_amount }}</td>
<td>{{ contract.budget_hours }}</td>
<td>{{ contract.total_hours_spent }}</td>
<td>${{ contract.total_money_spent|floatformat:2 }}</td>
<td>${{ contract.remaining_money|floatformat:2 }}</td>
<td>{{ contract.projected_end_date }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<hr>
<p> Create <a href="{% url 'new_contract' %}">new</a> contract.</p>
{% else %}
<p> There are no contracts. <a href="{% url 'new_contract' %}">Please make one</a></p>
<p>Create a <a href="{% url 'new_contract' %}" class="text-cyber-cyan"
style="text-decoration: underline;">new contract</a>.</p>
{% else %}
<p style="color: var(--text-muted);">There are no contracts. <a href="{% url 'new_contract' %}"
class="text-cyber-cyan" style="text-decoration: underline;">Please make one</a>.</p>
{% endif %}
</body>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/moment@2.29.4/moment.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@1.0.1/dist/chartjs-adapter-moment.min.js"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const chartDataRaw = '{{ chart_data_json|escapejs }}';
if (chartDataRaw) {
Chart.defaults.color = '#e0e0e0';
Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.1)';
const chartData = JSON.parse(chartDataRaw);
chartData.forEach((data, index) => {
const canvas = document.getElementById('chart_' + (index + 1));
if (!canvas) return;
const ctx = canvas.getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
datasets: [
{
label: 'Historical Burn',
data: [
{ x: data.start_date, y: data.budget },
{ x: data.today, y: data.remaining }
],
borderColor: '#00f3ff', /* Cyber cyan */
backgroundColor: '#00f3ff',
fill: false,
tension: 0.1
},
{
label: 'Projected Burn',
data: [
{ x: data.today, y: data.remaining },
{ x: data.projected_end, y: 0 }
],
borderColor: '#bc13fe', /* Neon purple */
borderDash: [5, 5],
backgroundColor: '#bc13fe',
fill: false,
tension: 0.1
}
]
},
options: {
responsive: true,
plugins: {
title: {
display: true,
text: data.name + ' Burn Rate ($)',
color: '#ffffff'
},
legend: {
labels: { color: '#e0e0e0' }
}
},
scales: {
x: {
type: 'time',
time: { unit: 'month' },
title: { display: true, text: 'Date', color: '#a0a0a0' },
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: '#a0a0a0' }
},
y: {
beginAtZero: true,
title: { display: true, text: 'Remaining Budget ($)', color: '#a0a0a0' },
grid: { color: 'rgba(255, 255, 255, 0.05)' },
ticks: { color: '#a0a0a0' }
}
}
}
});
});
}
});
</script>
{% endblock %}
@@ -0,0 +1,24 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Edit Time Log - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<h1 class="section-title" style="text-align: left;">Edit Time Log</h1>
<div class="card" style="max-width: 600px;">
<form method="POST">
{% csrf_token %}
{{ form.as_p }}
<div style="margin-top: 1.5rem; display: flex; gap: 1rem; align-items: center;">
<button type="submit" class="btn">Update Record</button>
<a href="{% url 'time_logs' %}" class="btn"
style="background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1);">Cancel</a>
</div>
</form>
</div>
</div>
</div>
{% endblock %}
@@ -1,116 +1,108 @@
<!doctype html>
{% extends "base.html" %}
{% load static %}
<html lang="en"">
<head>
<title>Accounting</title>
<link href="{% static 'financial/css/material-dashboard.css' %}" rel="stylesheet" >
<link rel="icon" type="image/xicon" href="{% static 'public/img/logo.png' %}">
<body class="g-sidenav-show bg-gray-200" >
<div class="sidenav-header">
<ul class="navbar-item">
<li class="nav-item">
Dashboard
</li>
<li class="nav-item">
Settings
</li>
</ul>
</div>
<main class="main-content position-relative max-height-vh-100 h-100 border-radius-lg">
<nav class="navbar navbar-main navbar-expand-lg px-0 mx-4 shadow-none border-radius-xl" id="navbarBlur" data-scroll="true">
<div class="container-fluid py-1 px-3">
<nav aria-label="breadcrumb">
<h6>Dashboard</h6>
</nav>
</div>
</nav>
<div class="container-fluid py-4">
<div class="row">
<div class="col-xl-3 col-sm-4 mb-xl-o mb-4">
<div class="card">
<div class="card-header p-3 pt-2">
<a href="{% url 'contracts' %}">
Contracts
{% block title %}Accounting Dashboard{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<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>
<p class="card-text">Overview of all active and past contracts and their scopes.</p>
</a>
<a href="{% url 'new_contract' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="new_contract">
<span class="card-title">New Contract</span>
<p class="card-text">Establish a new project contract to track budgets.</p>
</a>
<a href="{% url 'new_employee' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="new_employee">
<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">{% 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>
<p class="card-text">Generate comprehensive reports for billing.</p>
</a>
</div>
</div class="card-body">
<p> put picture here</p>
</div>
</div>
</div>
{% if is_worker %}
<div class="col-xl-3 col-sm-4 mb-xl-o mb-4">
<div class="card">
<div class="card-header p-3 pt-2">
<a href="{% url 'profile' %}">
Profile
</a>
<h2 class="section-title" style="font-size: 2rem; margin-bottom: 2rem;">Contracts Overview</h2>
<div class="table-responsive">
{% if contracts %}
<table class="table">
<thead>
<tr>
<th>Contract Name</th>
<th>Budget Hours</th>
<th>Logged Hours</th>
</tr>
</thead>
<tbody>
{% for c in contracts %}
<tr>
<td><a href="{% url 'contract_detail' c.slug %}">{{ c.name }}</a></td>
<td>{{ c.budget_hours }}</td>
<td>{{ c.total_logged }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-muted);">No contracts available.</p>
{% endif %}
</div>
</div class="card-body">
<p> put picture here</p>
</div>
</div>
</div>
{% endif %}
{% if is_worker %}
<div class="col-xl-3 col-sm-4 mb-xl-o mb-4">
<div class="card">
<div class="card-header p-3 pt-2">
<a href="{% url 'Timekeeping' %}">
Timekeeping
</a>
</div>
<hr style="margin: 40px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
</div class="card-body">
<p> put picture here</p>
</div>
</div>
</div>
{% endif %}
{% if is_manager %}
<div class="col-xl-3 col-sm-4 mb-xl-o mb-4">
<div class="card">
<div class="card-header p-3 pt-2">
<a href="{% url 'Timeapproval' %}">
Time Approval
</a>
</div>
<div class="card-body">
<p> put picture here</p>
</div>
</div>
</div>
{% endif %}
{% if is_procurment_officer %}
<div class="col-xl-3 col-sm-4 mb-xl-o mb-4">
<div class="card">
<div class="card-header p-3 pt-2">
</div>
<div class="card-body">
<p> put picture here</p>
</div>
</div>
</div>
{% endif %}
{% if is_finance %}
<div class="col-xl-3 col-sm-4 mb-xl-o mb-4">
<div class="card">
<div class="card-header p-3 pt-2">
<a href="{% url 'contracts' %}">
Contracts
</a>
</div>
<div class="card-body">
<p> put picture here</p>
</div>
</div>
</div>
<h2 class="section-title" style="font-size: 2rem; margin-bottom: 2rem;">Employee Hours per Contract</h2>
<div class="table-responsive">
{% if employee_data %}
<table class="table">
<thead>
<tr>
<th>Employee Name</th>
{% for c in contracts %}
<th>{{ c.name }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in employee_data %}
<tr>
<td>{{ row.employee }}</td>
{% for ch in row.contract_hours %}
<td>{{ ch.hours }}</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-muted);">No employees available.</p>
{% endif %}
</div>
</div>
</main>
<h1>Accouting</h1>
</body>
</div>
{% endblock %}
@@ -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 %}
@@ -0,0 +1,25 @@
{% extends "base.html" %}
{% load static %}
{% block title %}New Employee - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<div style="margin-bottom: 2rem;">
<a href="{% url 'financial_index' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Back to
Dashboard</a>
</div>
<h1 class="section-title" style="text-align: left;">New Employee</h1>
<div class="card" style="max-width: 600px;">
<form method="post" action="{% url 'new_employee' %}">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Create Employee</button>
</form>
</div>
</div>
</div>
{% endblock %}
@@ -1 +1,13 @@
<p> this page has not been created yet</p>
{% extends "base.html" %}
{% block title %}Page Not Found{% endblock %}
{% block content %}
<div class="section">
<div class="container" style="text-align: center; padding: 4rem 0;">
<h1 class="section-title" style="margin-bottom: 1.5rem;">Coming Soon</h1>
<p style="color: var(--text-muted); font-size: 1.2rem;">This page has not been created yet.</p>
<a href="{% url 'financial_index' %}" class="btn" style="margin-top: 2rem;">Return to Dashboard</a>
</div>
</div>
{% endblock %}
@@ -1,17 +1,42 @@
<!-- a lot of stuff-->
<!doctype html>
{% extends "base.html" %}
{% load static %}
<html lang="en"">
<head>
<title>Profile</title>
<link href="{% static 'financial/css/material-dashboard.css' %}" rel="stylesheet" >
<link rel="icon" type="image/xicon" href="{% static 'public/img/logo.png' %}">
<body class="g-sidenav-show bg-gray-200" >
<main class="main-content position-relative max-height-vh-100 h-100 border-radius-lg">
{% block title %}Profile - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<h1 class="section-title" style="text-align: left;">Profile</h1>
<h1>Profile</h1>
{{ form }}
</body>
{% 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 %}
@@ -0,0 +1,44 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Client Reports - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h1 class="section-title" style="margin-bottom: 0;">Client Reports</h1>
<a href="{% url 'financial_index' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Back to
Dashboard</a>
</div>
<div class="table-responsive">
{% if contracts %}
<table class="table">
<thead>
<tr>
<th>Contract Name</th>
<th>Total Budget (Hours)</th>
<th>Total Logged (Hours)</th>
<th>Remaining Budget (Hours)</th>
</tr>
</thead>
<tbody>
{% for c in contracts %}
<tr>
<td>{{ c.name }}</td>
<td>{{ c.budget_hours }}</td>
<td>{{ c.total_logged }}</td>
<td style="{% if c.remaining_budget < 0 %}color: #ff4444; font-weight: bold;{% endif %}">{{
c.remaining_budget }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-muted); text-align: center; padding: 2rem;">No data available to report.</p>
{% endif %}
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,174 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Time Logs - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<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_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>
<div class="card" style="margin-bottom: 2rem; padding: 1.25rem;">
<form method="get" action="{% url 'time_logs' %}"
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; align-items: end;">
<div>
<label for="filter-employee"
style="display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.35rem;">Employee <span style="opacity: 0.75;">(Ctrl/Cmd+click for multiple)</span></label>
<select name="employee" id="filter-employee" multiple size="5"
style="width: 100%; padding: 0.5rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); border-radius: 6px;">
{% for emp in employees %}
<option value="{{ emp.id }}" {% if emp.id|stringformat:"s" in filters.employees %}selected{% endif %}>{{ emp }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="filter-month"
style="display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.35rem;">Month</label>
<input type="month" name="month" id="filter-month" value="{{ filters.month }}"
style="width: 100%; padding: 0.5rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); border-radius: 6px;">
</div>
<div>
<label for="filter-contract"
style="display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.35rem;">Contract</label>
<select name="contract" id="filter-contract"
style="width: 100%; padding: 0.5rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); border-radius: 6px;">
<option value="">All contracts</option>
{% for c in contracts %}
<option value="{{ c.id }}" {% if filters.contract == c.id|stringformat:"s" %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="filter-charge-number"
style="display: block; font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.35rem;">Charge Number</label>
<select name="charge_number" id="filter-charge-number"
style="width: 100%; padding: 0.5rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.15); border-radius: 6px;">
<option value="">All charge numbers</option>
{% for cn in charge_numbers %}
<option value="{{ cn.id }}" {% if filters.charge_number == cn.id|stringformat:"s" %}selected{% endif %}>{{ cn }} ({{ cn.contract.name }})</option>
{% endfor %}
</select>
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<button type="submit" class="btn" style="padding: 0.5rem 1.25rem; font-size: 0.9rem;">Apply</button>
<a href="{% url 'time_logs' %}" class="btn"
style="padding: 0.5rem 1.25rem; font-size: 0.9rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1);">Clear</a>
</div>
</form>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<div class="card" style="padding: 1.25rem;">
<h2 style="font-size: 1rem; margin: 0 0 1rem 0; color: var(--text-muted);">Hours by Contract</h2>
{% if contract_totals %}
<table class="table" style="margin-bottom: 0;">
<thead>
<tr>
<th>Contract</th>
<th style="text-align: right;">Hours</th>
</tr>
</thead>
<tbody>
{% for row in contract_totals %}
<tr>
<td>{{ row.charge_number__contract__name }}</td>
<td style="text-align: right;">{{ row.total_hours|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-muted); margin: 0;">No contract totals for current filters.</p>
{% endif %}
</div>
<div class="card" style="padding: 1.25rem;">
<h2 style="font-size: 1rem; margin: 0 0 1rem 0; color: var(--text-muted);">Hours by Charge Number</h2>
{% if charge_number_totals %}
<table class="table" style="margin-bottom: 0;">
<thead>
<tr>
<th>Charge Number</th>
<th>Contract</th>
<th style="text-align: right;">Hours</th>
</tr>
</thead>
<tbody>
{% for row in charge_number_totals %}
<tr>
<td>{{ row.charge_number__name|default:"—" }}</td>
<td>{{ row.charge_number__contract__name }}</td>
<td style="text-align: right;">{{ row.total_hours|floatformat:2 }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="color: var(--text-muted); margin: 0;">No charge number totals for current filters.</p>
{% endif %}
</div>
</div>
<p style="margin: -1rem 0 2rem 0; font-size: 0.95rem; color: var(--text-muted);">
<strong style="color: var(--text-color);">Grand total:</strong> {{ grand_total|floatformat:2 }} hrs
<span style="opacity: 0.75;">(all filtered rows, including entries without a charge number)</span>
</p>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Employee</th>
<th>Charge Number</th>
<th>Date</th>
<th>Start Time</th>
<th>End Time</th>
<th>Duration (hrs)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for log in logs %}
<tr>
<td>{{ log.timeCard.employee }}</td>
<td>{{ log.charge_number }}</td>
<td>{{ log.date }}</td>
<td>{{ log.start_time|default_if_none:"" }}</td>
<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;"
onsubmit="return confirm('Are you sure you want to delete this time log?');">
{% csrf_token %}
<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 %}
<tr>
<td colspan="7" style="color: var(--text-muted); text-align: center; padding: 2rem;">No time
logs found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Timekeeping - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<div style="margin-bottom: 2rem;">
<a href="{% url 'financial_index' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Back to
Dashboard</a>
</div>
<h1 class="section-title" style="text-align: left;">Log Time</h1>
{% if error %}
<div class="alert alert-danger" style="max-width: 600px; margin-bottom: 1.5rem;">
{{ error }}
</div>
{% endif %}
<div class="card" style="max-width: 600px;">
<form method="post" action="{% url 'Timekeeping' %}">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Log Time</button>
</form>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,39 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Update Charge Number - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<div style="margin-bottom: 2rem;">
<a href="{% url 'contract_detail' charge_number.contract.slug %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Back to {{ charge_number.contract.name }}</a>
</div>
<h1 class="section-title" style="text-align: left;">Update Charge Number: {{ charge_number.slug }}</h1>
{% if form.errors %}
<div class="alert alert-danger" style="max-width: 600px; margin-bottom: 1.5rem; padding: 1rem; border: 1px solid #ff4444; border-radius: 4px; background: rgba(255, 68, 68, 0.1);">
<ul style="margin: 0; padding-left: 20px; color: #ff4444;">
{% for field in form %}
{% for error in field.errors %}
<li><strong>{{ field.label }}:</strong> {{ error }}</li>
{% endfor %}
{% endfor %}
{% for error in form.non_field_errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="card" style="max-width: 600px;">
<form method="post" action="{% url 'update_charge_number' charge_number.slug %}">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Update Charge Number</button>
</form>
</div>
</div>
</div>
{% endblock %}
+158 -2
View File
@@ -1,3 +1,159 @@
from django.test import TestCase
from django.contrib.auth.models import User
from django.test import Client, TestCase
from django.urls import reverse
# Create your tests here.
from financial.models import (
AddressModel,
ChargeNumber,
Contract,
Employee,
TimeCard,
TimeCardCell,
UserProfile,
set_user_type,
)
from financial.permissions import get_employees, is_client_user, is_employee_user
class UserProfileSignalTests(TestCase):
def test_new_user_gets_client_profile_not_employee(self):
user = User.objects.create_user(username="newbie", password="pass")
self.assertTrue(UserProfile.objects.filter(user=user, user_type=UserProfile.UserType.CLIENT).exists())
self.assertFalse(Employee.objects.filter(user=user).exists())
class SetUserTypeTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="worker", password="pass")
UserProfile.objects.filter(user=self.user).delete()
def test_set_employee_creates_employee_record(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
self.assertTrue(Employee.objects.filter(user=self.user).exists())
self.assertEqual(self.user.profile.user_type, UserProfile.UserType.EMPLOYEE)
def test_set_client_removes_employee_without_time_entries(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
set_user_type(self.user, UserProfile.UserType.CLIENT)
self.assertFalse(Employee.objects.filter(user=self.user).exists())
self.assertEqual(
UserProfile.objects.get(user=self.user).user_type,
UserProfile.UserType.CLIENT,
)
def test_cannot_set_client_with_time_entries(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
employee = Employee.objects.get(user=self.user)
contract = Contract.objects.create(
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
name="Test Contract",
)
charge = ChargeNumber.objects.create(
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
contract=contract,
)
time_card = TimeCard.objects.create(employee=employee)
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=2.0)
with self.assertRaises(ValueError):
set_user_type(self.user, UserProfile.UserType.CLIENT)
class EmployeeFilterTests(TestCase):
def setUp(self):
self.employee_user = User.objects.create_user(username="emp", password="pass")
self.client_user = User.objects.create_user(username="cli", password="pass")
set_user_type(self.employee_user, UserProfile.UserType.EMPLOYEE)
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
def test_get_employees_excludes_clients(self):
employees = list(get_employees())
self.assertEqual(len(employees), 1)
self.assertEqual(employees[0].user, self.employee_user)
def test_permission_helpers(self):
self.assertTrue(is_employee_user(self.employee_user))
self.assertFalse(is_employee_user(self.client_user))
self.assertTrue(is_client_user(self.client_user))
self.assertFalse(is_client_user(self.employee_user))
class FinancialAccessTests(TestCase):
def setUp(self):
self.client = Client()
self.admin = User.objects.create_superuser(username="admin", password="pass", email="a@test.com")
self.employee = User.objects.create_user(username="employee", password="pass")
self.client_user = User.objects.create_user(username="client", password="pass")
set_user_type(self.employee, UserProfile.UserType.EMPLOYEE)
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
contract = Contract.objects.create(
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
name="C1",
budget_hours=100,
)
charge = ChargeNumber.objects.create(
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
contract=contract,
)
emp_record = Employee.objects.get(user=self.employee)
time_card = TimeCard.objects.create(employee=emp_record)
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=4.0)
def test_client_can_view_reports_readonly(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("client_reports"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "C1")
def test_client_cannot_log_time(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("Timekeeping"))
self.assertEqual(response.status_code, 403)
def test_client_can_view_time_logs_without_edit(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("time_logs"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Read only")
self.assertNotContains(response, 'href="/financial/time_logs/')
def test_employee_can_access_timekeeping(self):
self.client.login(username="employee", password="pass")
response = self.client.get(reverse("Timekeeping"))
self.assertEqual(response.status_code, 200)
def test_client_redirected_from_financial_home_to_reports(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("financial_home"))
self.assertRedirects(response, reverse("client_reports"))
def test_admin_can_manage_users(self):
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("manage_users"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("manage_users"), {
"user": self.client_user.id,
"user_type": UserProfile.UserType.EMPLOYEE,
})
self.assertRedirects(response, reverse("manage_users"))
self.client_user.refresh_from_db()
self.assertEqual(self.client_user.profile.user_type, UserProfile.UserType.EMPLOYEE)
def test_time_logs_only_lists_employees(self):
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("time_logs"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "employee")
employees = response.context["employees"]
self.assertEqual(employees.count(), 1)
def test_new_user_not_in_employee_dropdown(self):
extra = User.objects.create_user(username="extra", password="pass")
set_user_type(extra, UserProfile.UserType.CLIENT)
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("time_logs"))
employees = response.context["employees"]
usernames = [e.user.username for e in employees]
self.assertIn("employee", usernames)
self.assertNotIn("extra", usernames)
+9 -2
View File
@@ -3,15 +3,22 @@ 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"),
path("time_logs/<int:log_id>/delete", views.delete_time_log, name="delete_time_log"),
path("timeapproval", views.timeapproval, name="Timeapproval"),
path("contracts", views.contracts, name="contracts"),
path("<str:contract_slug>/contract_detail", views.contract_detail, name="contract_detail"),
path("new_contract", views.new_contract, name="new_contract"),
path("new_charge_number", views.new_charge_number, name="new_charge_number"),
path("new_employee", views.new_employee, name="new_employee"),
path("<str:contract_slug>/new_charge_number", views.new_charge_number, name="new_charge_number"),
path("<str:charge_number_slug>/update_charge_number", views.update_charge_number, name="update_charge_number"),
#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"),
]
+401 -43
View File
@@ -1,48 +1,185 @@
from django.shortcuts import render, redirect
from .forms import EmployeeForm, ContractForm, ChargeNumberForm
from .models import Contract
# Create your views here.
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
# PAGES TO CREATE
# dashboard
# log in
# log out
# password reset
# employee timecard
# time card approval
# contract
# charge number
# user management (?)
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,
)
@financial_admin_required
def index(request):
permissions = []
context = {
'is_procurment_officer':True,
'is_worker':True,
'is_manager':True,
'is_finance': True
}
return render(request, "financial/index.html", context)
def contracts(request):
contracts = Contract.objects.all()
return render(request, 'financial/contracts.html', {'contracts':contracts})
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 = get_employees()
employee_data = []
for e in employees:
contract_hours = []
for c in contracts:
total_e_c = TimeCardCell.objects.filter(charge_number__contract=c, timeCard__employee=e).aggregate(Sum('hour'))['hour__sum']
contract_hours.append({'contract': c, 'hours': total_e_c if total_e_c else 0.0})
employee_data.append({'employee': e, 'contract_hours': contract_hours})
return render(request, "financial/index.html", {
'contracts': contracts,
'employee_data': employee_data,
})
@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)
if form.is_valid():
form.save()
return redirect('financial_index')
else:
form = NewEmployeeForm()
return render(request, 'financial/new_employee.html', {"form": form})
@financial_admin_required
def contracts(request):
contracts_list = Contract.objects.all()
today = timezone.now().date()
chart_data_list = []
for c in contracts_list:
cells = TimeCardCell.objects.filter(charge_number__contract=c).select_related('timeCard__employee').order_by('date')
total_hours = sum((cell.hour or 0.0) for cell in cells)
total_money = sum((cell.hour or 0.0) * cell.timeCard.employee.hourly_salary for cell in cells)
c.total_hours_spent = total_hours
c.total_money_spent = total_money
c.remaining_hours = max(0, c.budget_hours - total_hours)
budget_amt = c.funded_amount if c.funded_amount > 0 else c.proposed_amount
c.remaining_money = max(0, budget_amt - total_money)
proj_end = None
if cells.exists():
first_date = cells.first().date or c.baseline_start or today
last_date = cells.last().date or today
days_elapsed = (last_date - first_date).days
if days_elapsed <= 0:
days_elapsed = 1
daily_hour_burn = total_hours / days_elapsed
daily_money_burn = total_money / days_elapsed
days_out_hours = (c.remaining_hours / daily_hour_burn) if daily_hour_burn > 0 else 9999
days_out_money = (c.remaining_money / daily_money_burn) if daily_money_burn > 0 else 9999
days_out = max(days_out_hours, days_out_money)
if days_out < 9999:
proj_end = last_date + timedelta(days=int(days_out))
c.projected_end_date = proj_end if proj_end else "N/A"
start_dt = str(c.baseline_start or today)
end_dt = str(proj_end or (today + timedelta(days=30)))
chart_data_list.append({
'name': c.name,
'start_date': start_dt,
'today': str(today),
'projected_end': end_dt,
'budget': float(budget_amt),
'spent': float(total_money),
'remaining': float(c.remaining_money)
})
return render(request, 'financial/contracts.html', {
'contracts': contracts_list,
'chart_data_json': json.dumps(chart_data_list)
})
@financial_admin_required
def contract_detail(request, contract_slug):
contract = Contract.objects.filter(slug=contract_slug)
contract = Contract.objects.filter(slug=contract_slug).first()
charge_numbers = contract.chargenumber_set.all() if contract else []
mermaid_gantt_lines = []
has_dates = False
for cn in charge_numbers:
if cn.start_date and cn.end_date:
has_dates = True
start = cn.start_date.strftime("%Y-%m-%d")
end = cn.end_date.strftime("%Y-%m-%d")
slug_label = cn.name or cn.slug or f"ID-{cn.id}"
mermaid_gantt_lines.append(f" {slug_label} : {start}, {end}")
mermaid_gantt = None
if has_dates:
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 = contract.get_evm_data() if contract else {}
evm_chart_json = json.dumps({
'time_series': evm.get('time_series', []),
'spi': evm.get('spi', 0),
'cpi': evm.get('cpi', 0),
})
if request.method == 'POST':
form = ContractForm(request.POST, instance=contract[0])
form = ContractForm(request.POST, instance=contract)
if form.is_valid():
form.save()
return redirect('contracts')
else:
form = ContractForm(instance = contract[0])
form = ContractForm(instance=contract)
charge_number_form = ChargeNumberForm()
# TODO: handle multiple better but we can assume there is only one
return render(request, 'financial/contract_detail.html', {
'is_new': False,
'form': form,
'charge_number_form': charge_number_form,
'contract': contract,
'charge_numbers': charge_numbers,
'mermaid_gantt': mermaid_gantt,
'evm': evm,
'evm_chart_json': evm_chart_json,
})
return render(request, 'financial/contract_detail.html', {'is_new': False, 'form': form, 'charge_number_form':charge_number_form, 'contract': contract[0]})
@financial_admin_required
def new_contract(request):
if request.method == "POST":
form = ContractForm(request.POST)
@@ -50,30 +187,251 @@ def new_contract(request):
form.save()
return redirect('contracts')
else:
return render(request, 'financial/contract_detail.html', {"form": ContractForm(), 'is_new': True})
form = ContractForm()
return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True})
else:
return render(request, 'financial/contract_detail.html', {"form": ContractForm(), 'is_new': True})
def update_charge_number(request, charge_number_slug):
return render(request, 'financial/not_created.html', {})
def new_charge_number(request, charge_number_slug):
return render(request, 'financial/not_created.html', {})
@financial_write_required
def timekeeping(request):
return render(request, 'financial/not_created.html', {})
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():
if is_financial_admin(request.user) and not employee:
messages.error(request, "Admin must have an Employee profile to log time here.")
return redirect('time_logs')
time_card, _ = TimeCard.objects.get_or_create(
employee=employee,
startDate=timezone.now().date(),
endDate=timezone.now().date(),
)
cell = form.save(commit=False)
cell.timeCard = time_card
cell.save()
return redirect('financial_home')
else:
form = TimeLogForm()
return render(request, 'financial/timekeeping.html', {'form': form})
@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 = []
for raw in request.GET.getlist('employee'):
if raw:
try:
employee_ids.append(int(raw))
except (ValueError, TypeError):
pass
month = request.GET.get('month')
contract_id = request.GET.get('contract')
charge_number_id = request.GET.get('charge_number')
if employee_ids:
logs = logs.filter(timeCard__employee_id__in=employee_ids)
if month:
try:
year, mon = month.split('-', 1)
logs = logs.filter(date__year=int(year), date__month=int(mon))
except (ValueError, TypeError):
pass
if contract_id:
logs = logs.filter(charge_number__contract_id=contract_id)
if charge_number_id:
logs = logs.filter(charge_number_id=charge_number_id)
charge_numbers = ChargeNumber.objects.select_related('contract').order_by('contract__name', 'name')
if contract_id:
charge_numbers = charge_numbers.filter(contract_id=contract_id)
contract_totals = (
logs.filter(charge_number__isnull=False)
.values('charge_number__contract_id', 'charge_number__contract__name')
.annotate(total_hours=Sum('hour'))
.order_by('charge_number__contract__name')
)
charge_number_totals = (
logs.filter(charge_number__isnull=False)
.values(
'charge_number_id',
'charge_number__name',
'charge_number__contract__name',
)
.annotate(total_hours=Sum('hour'))
.order_by('charge_number__contract__name', 'charge_number__name')
)
grand_total = logs.aggregate(total_hours=Sum('hour'))['total_hours'] or 0.0
return render(request, 'financial/time_logs.html', {
'logs': logs,
'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 '',
'contract': contract_id or '',
'charge_number': charge_number_id or '',
},
})
@financial_write_required
def edit_time_log(request, log_id):
log_entry = TimeCardCell.objects.filter(id=log_id).first()
if not log_entry:
return redirect('time_logs')
if request.method == "POST":
form = TimeLogForm(request.POST, instance=log_entry)
if form.is_valid():
form.save()
return redirect('time_logs')
else:
form = TimeLogForm(instance=log_entry)
return render(request, 'financial/edit_time_log.html', {'form': form, 'log': log_entry})
@financial_write_required
def delete_time_log(request, log_id):
if request.method == "POST":
log_entry = TimeCardCell.objects.filter(id=log_id).first()
if log_entry:
log_entry.delete()
return redirect('time_logs')
@financial_access_required
def client_reports(request):
contracts = Contract.objects.all()
for c in contracts:
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,
'read_only': is_client_user(request.user),
})
@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:
return redirect('contracts')
if request.method == "POST":
form = ChargeNumberForm(request.POST, instance=charge_number)
if form.is_valid():
form.save()
return redirect('contract_detail', contract_slug=charge_number.contract.slug)
else:
form = ChargeNumberForm(instance=charge_number)
return render(request, 'financial/update_charge_number.html', {
'form': form,
'charge_number': charge_number,
})
@financial_admin_required
def new_charge_number(request, contract_slug):
contract = Contract.objects.filter(slug=contract_slug).first()
if request.method == "POST":
form = ChargeNumberForm(request.POST)
if form.is_valid():
charge_number = form.save(commit=False)
charge_number.contract = contract
charge_number.save()
return redirect('contract_detail', contract_slug=contract.slug)
return redirect('contract_detail', contract_slug=contract_slug)
@financial_admin_required
def timeapproval(request):
return render(request, 'financial/not_created.html', {})
@financial_admin_required
def chargenumber(request):
return render(request, 'financial/not_created.html', {})
@financial_admin_required
def procurement(request):
return render(request, 'financial/procurement.html', {})
@financial_access_required
def profile(request):
form = EmployeeForm()
return render(request, 'financial/profile.html', {'form': form})
# def contract_detail(request, contract_id):
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,
})
View File
+8
View File
@@ -0,0 +1,8 @@
from django.contrib import admin
from .models import Item
@admin.register(Item)
class ItemAdmin(admin.ModelAdmin):
list_display = ('title', 'status', 'order', 'created_at', 'updated_at')
list_filter = ('status',)
search_fields = ('title', 'description')
+5
View File
@@ -0,0 +1,5 @@
from django.apps import AppConfig
class PlanningConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'planning'
@@ -0,0 +1,29 @@
# Generated by Django 5.0 on 2026-03-22 23:47
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='Item',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255)),
('description', models.TextField(blank=True)),
('status', models.CharField(choices=[('TODO', 'Todo'), ('IN_PROGRESS', 'In Progress'), ('DONE', 'Done')], default='TODO', max_length=20)),
('order', models.IntegerField(default=0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'ordering': ['status', 'order', '-created_at'],
},
),
]
@@ -0,0 +1,20 @@
# Generated by Django 5.0 on 2026-03-23 00:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0010_alter_employee_manager_alter_employee_phonenumber'),
('planning', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='item',
name='contract',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='financial.contract'),
),
]
@@ -0,0 +1,14 @@
from django.db import migrations
def create_groups(apps, schema_editor):
Group = apps.get_model('auth', 'Group')
Group.objects.get_or_create(name='developer')
Group.objects.get_or_create(name='stakeholder')
class Migration(migrations.Migration):
dependencies = [
('planning', '0002_item_contract'),
]
operations = [
migrations.RunPython(create_groups),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.0 on 2026-03-23 07:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('financial', '0011_chargenumber_budget_hours'),
('planning', '0003_create_groups'),
]
operations = [
migrations.RemoveField(
model_name='item',
name='contract',
),
migrations.AddField(
model_name='item',
name='charge_number',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', to='financial.chargenumber'),
),
]
+26
View File
@@ -0,0 +1,26 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
class Item(models.Model):
class Status(models.TextChoices):
TODO = 'TODO', _('Todo')
IN_PROGRESS = 'IN_PROGRESS', _('In Progress')
DONE = 'DONE', _('Done')
title = models.CharField(max_length=255)
description = models.TextField(blank=True)
status = models.CharField(
max_length=20,
choices=Status.choices,
default=Status.TODO,
)
order = models.IntegerField(default=0)
charge_number = models.ForeignKey('financial.ChargeNumber', on_delete=models.SET_NULL, null=True, blank=True, related_name='tickets')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
ordering = ['status', 'order', '-created_at']
def __str__(self):
return self.title
@@ -0,0 +1,171 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Planning Backlog | AI ML Operations{% endblock %}
{% block content %}
<div class="container section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h2 class="section-title" style="margin-bottom: 0;">Backlog</h2>
<div>
<a href="{% url 'planning:board_view' %}" class="btn"
style="padding: 0.5rem 1rem; border-radius: 8px; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid var(--primary-color);">View
Board</a>
{% if is_developer %}
<button class="btn" id="openAddModalBtn" style="padding: 0.5rem 1rem; border-radius: 8px;">Add Item</button>
{% endif %}
</div>
</div>
<form method="get" action="{% url 'planning:backlog_view' %}"
style="margin-bottom: 2rem; display: flex; gap: 1rem; align-items: center; flex-wrap: wrap;">
<div style="display: flex; gap: 0.5rem; align-items: center;">
<label for="status" style="color: var(--text-muted);">Status:</label>
<select name="status" id="status" class="form-control" style="width: 150px; margin-bottom: 0;"
onchange="this.form.submit()">
<option value="">All</option>
{% for status_value, status_label in statuses %}
<option value="{{ status_value }}" {% if request.GET.status == status_value %}selected{% endif %}>{{
status_label }}</option>
{% endfor %}
</select>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<label for="contract" style="color: var(--text-muted);">Contract:</label>
<select name="contract" id="contract" class="form-control" style="width: 200px; margin-bottom: 0;"
onchange="this.form.submit()">
<option value="">All</option>
{% for contract in contracts %}
<option value="{{ contract.id }}" {% if request.GET.contract == contract.id|stringformat:"i" %}selected{% endif %}>{{ contract.name }}</option>
{% endfor %}
</select>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<label for="charge_number" style="color: var(--text-muted);">Charge Number:</label>
<select name="charge_number" id="charge_number" class="form-control" style="width: 200px; margin-bottom: 0;"
onchange="this.form.submit()">
<option value="">All</option>
{% for cn in charge_numbers %}
<option value="{{ cn.id }}" {% if request.GET.charge_number == cn.id|stringformat:"i" %}selected{% endif %}>{{ cn }}</option>
{% endfor %}
</select>
</div>
<a href="{% url 'planning:backlog_view' %}" class="btn"
style="padding: 0.5rem 1rem; border-radius: 8px; background: transparent; border: 1px solid var(--text-muted); color: var(--text-muted);">Clear
Filters</a>
</form>
<div class="table-responsive"
style="background: var(--surface-color); border-radius: 12px; border: 1px solid rgba(255, 255, 255, 0.05); padding: 1rem;">
<table class="table">
<thead>
<tr>
<th>Title</th>
<th>Charge Number</th>
<th>Status</th>
<th>Created</th>
<th>Last Updated</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr style="cursor: pointer; transition: background 0.2s;"
onmouseover="this.style.background='rgba(255,255,255,0.05)'"
onmouseout="this.style.background='transparent'" onclick="showItemDetails({{ item.id }})">
<td style="font-weight: 500; color: white;">{{ item.title }}</td>
<td>
{% if item.charge_number %}<span class="badge"
style="background: rgba(188, 19, 254, 0.2); color: var(--secondary-color); border: 1px solid var(--secondary-color);">{{
item.charge_number }} ({{ item.charge_number.get_percent_complete|floatformat:0
}}%)</span>
{% else %}
-
{% endif %}
</td>
<td><span class="badge" style="border: 1px solid var(--primary-color);">
{{ item.get_status_display }}</span></td>
<td style="color: var(--text-muted); font-size: 0.9rem;">{{ item.created_at|date:"M d, Y" }}</td>
<td style="color: var(--text-muted); font-size: 0.9rem;">{{ item.updated_at|date:"M d, Y" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-muted); padding: 2rem;">No items found.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Item Details Modal (Reused from Board) -->
<div id="itemDetailModal" class="modal" style="display: none; position: fixed; z-index: 2000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); backdrop-filter: blur(5px);">
<div class="modal-content" style="background-color: var(--surface-color); margin: 10% auto; padding: 2rem; border: 1px solid var(--primary-color); border-radius: 12px; width: 90%; max-width: 600px; color: var(--text-color); box-shadow: 0 0 30px rgba(0, 243, 255, 0.2);">
<span class="close" id="closeDetailModal" style="color: var(--text-muted); float: right; font-size: 28px; font-weight: bold; cursor: pointer;">&times;</span>
<div id="itemDetailContent">
<!-- Loaded via AJAX -->
</div>
</div>
</div>
<!-- Add Item Modal -->
<div id="addItemModal" class="modal" style="display: none; position: fixed; z-index: 2000; left: 0; top: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.8); backdrop-filter: blur(5px);">
<div class="modal-content" style="background-color: var(--surface-color); margin: 10% auto; padding: 2rem; border: 1px solid var(--primary-color); border-radius: 12px; width: 90%; max-width: 600px; color: var(--text-color); box-shadow: 0 0 30px rgba(0, 243, 255, 0.2);">
<span class="close" id="closeAddModal" style="color: var(--text-muted); float: right; font-size: 28px; font-weight: bold; cursor: pointer;">&times;</span>
<h2 style="color: var(--primary-color); margin-bottom: 1.5rem;">New Item</h2>
<form method="post" action="{% url 'planning:create_item' %}">
{% csrf_token %}
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Title</label>
<input type="text" name="title" class="form-control" required>
</div>
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Charge Number (QBD)</label>
<select name="charge_number" class="form-control">
<option value="">-- No Charge Number --</option>
{% for cn in qbd_charge_numbers %}
<option value="{{ cn.id }}">{{ cn.contract.name }} - {{ cn.slug }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Description</label>
<textarea name="description" class="form-control" rows="4"></textarea>
</div>
<input type="hidden" name="next" value="{{ request.path }}">
<button type="submit" class="btn">Create</button>
</form>
</div>
</div>
<script>
const detailModal = document.getElementById('itemDetailModal');
const closeDetailBtn = document.getElementById('closeDetailModal');
const detailContent = document.getElementById('itemDetailContent');
const addModal = document.getElementById('addItemModal');
const openAddBtn = document.getElementById('openAddModalBtn');
const closeAddBtn = document.getElementById('closeAddModal');
closeDetailBtn.onclick = () => detailModal.style.display = "none";
if (openAddBtn) openAddBtn.onclick = () => addModal.style.display = "block";
if (closeAddBtn) closeAddBtn.onclick = () => addModal.style.display = "none";
window.onclick = (event) => {
if (event.target == detailModal) detailModal.style.display = "none";
if (addModal && event.target == addModal) addModal.style.display = "none";
}
function showItemDetails(itemId) {
fetch(`/planning/item/${itemId}/`)
.then(res => res.text())
.then(html => {
detailContent.innerHTML = html;
detailModal.style.display = "block";
});
}
</script>
{% endblock %}
@@ -0,0 +1,343 @@
{% extends 'base.html' %}
{% load static %}
{% block title %}Planning Board | AI ML Operations{% endblock %}
{% block content %}
<style>
/* Custom Kanban styles to match the cyber aesthetic */
.kanban-board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
padding: 2rem 0;
align-items: start;
}
.kanban-column {
background: rgba(26, 26, 26, 0.6);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1.5rem;
min-height: 500px;
transition: border-color 0.3s;
}
.kanban-column h3 {
margin-bottom: 1rem;
color: var(--primary-color);
text-transform: uppercase;
font-size: 1.25rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.kanban-items {
min-height: 400px;
/* For drop zone */
}
.kanban-item {
background: var(--surface-color);
border: 1px solid rgba(0, 243, 255, 0.1);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
cursor: grab;
transition: transform 0.2s, box-shadow 0.2s;
}
.kanban-item:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 243, 255, 0.2);
border-color: var(--primary-color);
}
.kanban-item:active {
cursor: grabbing;
}
.kanban-item-title {
font-weight: 600;
margin-bottom: 0.5rem;
color: white;
}
.kanban-item-desc {
font-size: 0.85rem;
color: var(--text-muted);
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 2000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
backdrop-filter: blur(5px);
}
.modal-content {
background-color: var(--surface-color);
margin: 10% auto;
padding: 2rem;
border: 1px solid var(--primary-color);
border-radius: 12px;
width: 90%;
max-width: 600px;
color: var(--text-color);
box-shadow: 0 0 30px rgba(0, 243, 255, 0.2);
}
.close {
color: var(--text-muted);
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover {
color: var(--secondary-color);
}
.badge {
background: rgba(255, 255, 255, 0.1);
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.8rem;
color: white;
}
@media (max-width: 968px) {
.kanban-board {
grid-template-columns: 1fr;
}
}
</style>
<div class="container section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h2 class="section-title" style="margin-bottom: 0;">Planning Board</h2>
<div>
<a href="{% url 'planning:backlog_view' %}" class="btn"
style="padding: 0.5rem 1rem; border-radius: 8px; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid var(--primary-color);">View
Backlog</a>
{% if is_developer %}
<button class="btn" id="openAddModalBtn">Add Item</button>
{% endif %}
</div>
</div>
<div class="kanban-board">
<!-- TODO Column -->
<div class="kanban-column" id="col-TODO">
<h3>Todo <span class="badge">{{ todo_items.count }}</span></h3>
<div class="kanban-items" data-status="TODO">
{% for item in todo_items %}
<div class="kanban-item" draggable="{% if is_developer %}true{% else %}false{% endif %}"
data-id="{{ item.id }}">
<div class="kanban-item-title">{{ item.title }}</div>
{% if item.charge_number %}
<div style="margin-bottom: 0.5rem;"><span class="badge"
style="background: rgba(188, 19, 254, 0.2); color: var(--secondary-color); border: 1px solid var(--secondary-color);">
{{ item.charge_number.contract.name }} ({{ item.charge_number }})</span></div>
{% endif %}
{% if item.description %}
<div class="kanban-item-desc">{{ item.description|truncatechars:50 }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- In Progress Column -->
<div class="kanban-column" id="col-IN_PROGRESS">
<h3>In Progress <span class="badge">{{ in_progress_items.count }}</span></h3>
<div class="kanban-items" data-status="IN_PROGRESS">
{% for item in in_progress_items %}
<div class="kanban-item" draggable="{% if is_developer %}true{% else %}false{% endif %}"
data-id="{{ item.id }}">
<div class="kanban-item-title">{{ item.title }}</div>
{% if item.charge_number %}
<div style="margin-bottom: 0.5rem;"><span class="badge"
style="background: rgba(188, 19, 254, 0.2); color: var(--secondary-color); border: 1px solid var(--secondary-color);">
{{ item.charge_number.contract.name }} ({{ item.charge_number }})</span></div>
{% endif %}
{% if item.description %}
<div class="kanban-item-desc">{{ item.description|truncatechars:50 }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
<!-- Done Column -->
<div class="kanban-column" id="col-DONE">
<h3>Done <span class="badge">{{ done_items.count }}</span></h3>
<div class="kanban-items" data-status="DONE">
{% for item in done_items %}
<div class="kanban-item" draggable="{% if is_developer %}true{% else %}false{% endif %}"
data-id="{{ item.id }}">
<div class="kanban-item-title">{{ item.title }}</div>
{% if item.charge_number %}
<div style="margin-bottom: 0.5rem;"><span class="badge"
style="background: rgba(188, 19, 254, 0.2); color: var(--secondary-color); border: 1px solid var(--secondary-color);">
{{ item.charge_number.contract.name }} ({{ item.charge_number }})</span></div>
{% endif %}
{% if item.description %}
<div class="kanban-item-desc">{{ item.description|truncatechars:50 }}</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<!-- Add Item Modal -->
<div id="addItemModal" class="modal">
<div class="modal-content">
<span class="close" id="closeAddModal">&times;</span>
<h2 style="color: var(--primary-color); margin-bottom: 1.5rem;">New Item</h2>
<form method="post" action="{% url 'planning:create_item' %}">
{% csrf_token %}
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Title</label>
<input type="text" name="title" class="form-control" required>
</div>
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Charge Number
(QBD)</label>
<select name="charge_number" class="form-control">
<option value="">-- No Charge Number --</option>
{% for cn in qbd_charge_numbers %}
<option value="{{ cn.id }}">{{ cn.contract.name }} - {{ cn.slug }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label style="color: var(--text-muted); margin-bottom: 0.5rem; display: block;">Description</label>
<textarea name="description" class="form-control" rows="4"></textarea>
</div>
<input type="hidden" name="next" value="{{ request.path }}">
<button type="submit" class="btn"
data-tianji-event="planning_create_item">Create</button>
</form>
</div>
</div>
<!-- Item Details Modal -->
<div id="itemDetailModal" class="modal">
<div class="modal-content">
<span class="close" id="closeDetailModal">&times;</span>
<div id="itemDetailContent">
<!-- Loaded via AJAX -->
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Modal logic
const addModal = document.getElementById('addItemModal');
const openAddBtn = document.getElementById('openAddModalBtn');
const closeAddBtn = document.getElementById('closeAddModal');
if (openAddBtn) {
openAddBtn.onclick = () => addModal.style.display = "block";
}
if (closeAddBtn) {
closeAddBtn.onclick = () => addModal.style.display = "none";
}
const detailModal = document.getElementById('itemDetailModal');
const closeDetailBtn = document.getElementById('closeDetailModal');
const detailContent = document.getElementById('itemDetailContent');
closeDetailBtn.onclick = () => detailModal.style.display = "none";
window.onclick = (event) => {
if (event.target == addModal) addModal.style.display = "none";
if (event.target == detailModal) detailModal.style.display = "none";
}
// Drag and Drop Logic
const items = document.querySelectorAll('.kanban-item');
const dropzones = document.querySelectorAll('.kanban-items');
items.forEach(item => {
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/plain', e.target.closest('.kanban-item').dataset.id);
setTimeout(() => e.target.closest('.kanban-item').style.opacity = '0.5', 0);
});
item.addEventListener('dragend', (e) => {
e.target.closest('.kanban-item').style.opacity = '1';
});
item.addEventListener('click', (e) => {
const itemId = item.dataset.id;
fetch(`/planning/item/${itemId}/`)
.then(res => res.text())
.then(html => {
detailContent.innerHTML = html;
detailModal.style.display = "block";
});
});
});
dropzones.forEach(zone => {
zone.addEventListener('dragover', (e) => {
e.preventDefault(); // Necessary to allow dropping
zone.parentElement.style.borderColor = 'var(--primary-color)';
});
zone.addEventListener('dragleave', (e) => {
zone.parentElement.style.borderColor = 'rgba(255, 255, 255, 0.05)';
});
zone.addEventListener('drop', (e) => {
e.preventDefault();
zone.parentElement.style.borderColor = 'rgba(255, 255, 255, 0.05)';
const id = e.dataTransfer.getData('text/plain');
if (!id) return;
const draggable = document.querySelector(`.kanban-item[data-id='${id}']`);
if (!draggable) return;
zone.appendChild(draggable);
const newStatus = zone.dataset.status;
// Send AJAX update
fetch(`/planning/item/${id}/update/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token }}'
},
body: JSON.stringify({
status: newStatus,
order: Array.from(zone.children).indexOf(draggable)
})
}).then(res => res.json())
.then(data => {
if (data.status !== 'success') {
alert('Failed to update status');
} else if (window.aimlTrack) {
window.aimlTrack('planning_item_status_change', {
status: newStatus,
item_id: id
});
}
});
});
});
});
</script>
{% endblock %}
@@ -0,0 +1,113 @@
{% if is_developer %}
<form method="post" action="{% url 'planning:edit_item' item.pk %}">
{% csrf_token %}
<input type="hidden" name="next" id="editItemNext">
<div class="form-group" style="margin-bottom: 1.5rem;">
<label style="color: var(--text-muted); display: block; margin-bottom: 0.5rem;">Title</label>
<input type="text" name="title" class="form-control" value="{{ item.title }}"
style="font-size: 1.2rem; font-weight: bold; color: var(--primary-color);" required>
</div>
<div style="display: flex; gap: 1rem; margin-bottom: 1.5rem;">
<div class="form-group" style="flex: 1; margin-bottom: 0;">
<label style="color: var(--text-muted); display: block; margin-bottom: 0.5rem;">Status</label>
<select name="status" class="form-control">
{% for value, label in statuses %}
<option value="{{ value }}" {% if item.status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="flex: 1; margin-bottom: 0;">
<label style="color: var(--text-muted); display: block; margin-bottom: 0.5rem;">Charge Number</label>
<select name="charge_number" class="form-control">
<option value="">-- No Charge Number --</option>
{% for cn in qbd_charge_numbers %}
<option value="{{ cn.id }}" {% if item.charge_number_id == cn.id %}selected{% endif %}>{{ cn }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label style="color: var(--text-muted); display: block; margin-bottom: 0.5rem;">Description</label>
<textarea name="description" class="form-control" rows="6">{{ item.description }}</textarea>
</div>
<hr style="border: 0; border-top: 1px solid rgba(255,255,255,0.1); margin: 2rem 0 1rem;">
<div style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 1.5rem;">
Created: {{ item.created_at|date:"M d, Y H:i" }}<br>
Updated: {{ item.updated_at|date:"M d, Y H:i" }}
</div>
<div style="display: flex; justify-content: space-between;">
<button type="submit" class="btn" style="padding: 0.8rem 2rem;">Save Changes</button>
<button type="button" class="btn"
style="background: transparent; border: 1px solid #ff4444; color: #ff4444; padding: 0.8rem 2rem;"
onclick="if(confirm('Are you sure you want to delete this ticket?')) document.getElementById('deleteItemForm').submit();">Delete
Ticket</button>
</div>
</form>
<form id="deleteItemForm" method="post" action="{% url 'planning:delete_item' item.pk %}" style="display:none;">
{% csrf_token %}
<input type="hidden" name="next" id="deleteItemNext">
</form>
<script>
// Ensure form redirects to the current page upon save or delete
document.getElementById('editItemNext').value = window.location.pathname;
document.getElementById('deleteItemNext').value = window.location.pathname;
// Handle AJAX submission for editing
const editForm = document.querySelector('form[action*="edit"]');
if (editForm) {
editForm.onsubmit = (e) => {
e.preventDefault();
const formData = new FormData(editForm);
fetch(editForm.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': formData.get('csrf_token')
}
}).then(res => res.json())
.then(data => {
if (data.status === 'success') {
// Close modal and refresh the current page to show updates
// (Alternatively, we could update the item in the DOM dynamically)
location.reload();
} else {
alert('Error updating item: ' + (data.message || 'Unknown error'));
}
});
};
}
</script>
{% else %}
<h2 style="color: var(--primary-color); margin-bottom: 1rem;">{{ item.title }}</h2>
<div style="margin-bottom: 1.5rem; display: flex; gap: 1rem; align-items: center;">
<span
style="background: var(--surface-color); padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.8rem; border: 1px solid var(--secondary-color);">{{
item.get_status_display }}</span>
{% if item.charge_number %}
<span
style="background: rgba(188, 19, 254, 0.1); padding: 0.3rem 0.6rem; border-radius: 4px; font-size: 0.8rem; border: 1px solid var(--secondary-color); color: var(--secondary-color);">Charge
Number: <strong>{{ item.charge_number.contract.name }} ({{ item.charge_number.slug }})</strong> ({{
item.charge_number.get_percent_complete|floatformat:0 }}% Complete)</span>
{% endif %}
</div>
<div style="color: var(--text-color); line-height: 1.6; white-space: pre-wrap;">
{% if item.description %}
{{ item.description }}
{% else %}
<span style="color: var(--text-muted); font-style: italic;">No description provided.</span>
{% endif %}
</div>
<hr style="border: 0; border-top: 1px solid rgba(255,255,255,0.1); margin: 2rem 0 1rem;">
<div style="font-size: 0.8rem; color: var(--text-muted);">
Created: {{ item.created_at|date:"M d, Y H:i" }}<br>
Updated: {{ item.updated_at|date:"M d, Y H:i" }}
</div>
{% endif %}
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path
from . import views
app_name = 'planning'
urlpatterns = [
path('', views.board_view, name='board_view'),
path('backlog/', views.backlog_view, name='backlog_view'),
path('create/', views.create_item, name='create_item'),
path('item/<int:pk>/', views.item_detail, name='item_detail'),
path('item/<int:pk>/update/', views.update_item_status, name='update_item_status'),
path('item/<int:pk>/edit/', views.edit_item, name='edit_item'),
path('item/<int:pk>/delete/', views.delete_item, name='delete_item'),
]
+125
View File
@@ -0,0 +1,125 @@
import json
from django.shortcuts import render, get_object_or_404, redirect
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
from financial.models import Contract, ChargeNumber
from .models import Item
def is_developer(user):
return user.is_active and (user.is_superuser or user.groups.filter(name__iexact='developer').exists())
def developer_required(view_func):
def _wrapped_view(request, *args, **kwargs):
if is_developer(request.user):
return view_func(request, *args, **kwargs)
raise PermissionDenied
return _wrapped_view
@login_required
def board_view(request):
items = Item.objects.select_related('charge_number', 'charge_number__contract').all()
todo_items = items.filter(status=Item.Status.TODO)
in_progress_items = items.filter(status=Item.Status.IN_PROGRESS)
done_items = items.filter(status=Item.Status.DONE)
context = {
'todo_items': todo_items,
'in_progress_items': in_progress_items,
'done_items': done_items,
'statuses': Item.Status.choices,
'qbd_charge_numbers': ChargeNumber.objects.filter(charge_number_type='QBD'),
'is_developer': is_developer(request.user),
}
return render(request, 'planning/board.html', context)
@login_required
def backlog_view(request):
items = Item.objects.select_related('charge_number', 'charge_number__contract').all().order_by('-created_at')
status_filter = request.GET.get('status')
if status_filter and status_filter in dict(Item.Status.choices):
items = items.filter(status=status_filter)
context = {
'items': items,
'statuses': Item.Status.choices,
'qbd_charge_numbers': ChargeNumber.objects.filter(charge_number_type='QBD'),
'is_developer': is_developer(request.user),
}
return render(request, 'planning/backlog.html', context)
@login_required
@developer_required
@require_POST
def create_item(request):
title = request.POST.get('title')
description = request.POST.get('description', '')
charge_number_id = request.POST.get('charge_number')
next_url = request.POST.get('next', 'planning:board_view')
if title:
max_order = Item.objects.filter(status=Item.Status.TODO).count()
item = Item.objects.create(title=title, description=description, status=Item.Status.TODO, order=max_order)
if charge_number_id:
item.charge_number_id = charge_number_id
item.save()
return redirect(next_url)
@login_required
def item_detail(request, pk):
item = get_object_or_404(Item.objects.select_related('charge_number', 'charge_number__contract'), pk=pk)
context = {
'item': item,
'statuses': Item.Status.choices,
'qbd_charge_numbers': ChargeNumber.objects.filter(charge_number_type='QBD'),
'is_developer': is_developer(request.user),
}
return render(request, 'planning/partials/item_detail.html', context)
@login_required
@developer_required
@require_POST
def edit_item(request, pk):
item = get_object_or_404(Item, pk=pk)
item.title = request.POST.get('title', item.title)
item.description = request.POST.get('description', item.description)
item.status = request.POST.get('status', item.status)
charge_number_id = request.POST.get('charge_number')
if charge_number_id:
item.charge_number_id = charge_number_id
else:
item.charge_number = None
item.save()
next_url = request.POST.get('next', 'planning:board_view')
return redirect(next_url)
@login_required
@developer_required
@require_POST
def delete_item(request, pk):
item = get_object_or_404(Item, pk=pk)
item.delete()
next_url = request.POST.get('next', 'planning:board_view')
return redirect(next_url)
@login_required
@developer_required
@require_POST
def update_item_status(request, pk):
try:
data = json.loads(request.body)
new_status = data.get('status')
new_order = data.get('order')
item = get_object_or_404(Item, pk=pk)
if new_status and new_status in dict(Item.Status.choices):
item.status = new_status
if new_order is not None:
item.order = new_order
item.save()
return JsonResponse({'status': 'success'})
return JsonResponse({'status': 'error', 'message': 'Invalid status'}, status=400)
except Exception as e:
return JsonResponse({'status': 'error', 'message': str(e)}, status=400)
+21 -18
View File
@@ -5,26 +5,32 @@ from django.shortcuts import render, get_object_or_404
from django.urls import path
from django.template.loader import get_template
from django.core.mail import EmailMultiAlternatives
from django.template.response import TemplateResponse
# Register your models here.
@admin.register(Contact, site=admin.site)
class ContactAdmin(admin.ModelAdmin):
list_display = ("email", "name", "contacted")
list_filter = ("email", "name", "contacted")
search_fields = ("email", "name")
@admin.action(description='Send seelcted emails')
@admin.action(description="Send seelcted emails")
def send_emails(modeladmin, request, queryset):
for email in queryset:
success_count: int = 0
try:
from_email="info@aimloperations.com"
d={"title":email.subject,"content":email.body}
from_email = "AI ML Operations, LLC <info@aimloperations.com>"
d = {"title": email.subject, "content": email.body}
html_content = get_template(f"emails/marketing_email.html").render(d)
text_content = get_template(f"emails/marketing_email.txt").render(d)
msg = EmailMultiAlternatives(email.subject, text_content, from_email, [email.recipient])
msg = EmailMultiAlternatives(
email.subject, text_content, from_email, [email.recipient]
)
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=False)
@@ -36,29 +42,26 @@ def send_emails(modeladmin, request, queryset):
raise UserWarning(e)
modeladmin.message_user(request, f"{success_count} emails sent successfully.")
@admin.register(EmailMessage, site=admin.site)
class EmailMessageAdmin(admin.ModelAdmin):
change_form_template = 'admin/public/emailmessage/change_form.html'
list_display = ('subject', 'recipient', 'sent')
change_form_template = "admin/public/emailmessage/change_form.html"
list_display = ("subject", "recipient", "sent")
actions = [send_emails]
def get_urls(self):
urls = super().get_urls()
custom_urls = [
path('preview_email/<int:pk>/', self.admin_site.admin_view(self.preview_email), name="preview_email")
path(
"preview_email/<int:pk>/",
self.admin_site.admin_view(self.preview_email),
name="preview_email",
)
]
print(f'RETURNING: {custom_urls + urls}')
print(f"RETURNING: {custom_urls + urls}")
return custom_urls + urls
def preview_email(self, request, pk):
email_instance = get_object_or_404(EmailMessage, pk=pk)
context = {
"title":email_instance.subject,
"content":email_instance.body
}
return render(
request, 'public/preview_email.html', context
)
context = {"title": email_instance.subject, "content": email_instance.body}
return TemplateResponse(request, "public/preview_email.html", context)
+80
View File
@@ -0,0 +1,80 @@
import json
from django.conf import settings
from django.urls import reverse
from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries
def tianji_tracking(request):
return {
'tianji_enabled': getattr(settings, 'TIANJI_ENABLED', True),
'tianji_tracker_url': getattr(
settings,
'TIANJI_TRACKER_URL',
'https://tianji.aimloperations.com/tracker.js',
),
'tianji_website_id': getattr(
settings,
'TIANJI_WEBSITE_ID',
'cm7w80pyy020oddswy2evl957',
),
'page_name': getattr(getattr(request, 'resolver_match', None), 'url_name', ''),
}
def webmcp_context(request):
page_name = getattr(getattr(request, 'resolver_match', None), 'url_name', '')
services = [
{
**entry,
"url": request.build_absolute_uri(reverse(entry["slug"])),
}
for entry in get_service_entries()
]
page_lookup = {
url_name: {
"slug": url_name,
"name": title,
"summary": summary,
"url": request.build_absolute_uri(reverse(url_name)),
}
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
}
return {
'webmcp_enabled': getattr(settings, 'WEBMCP_ENABLED', False),
'webmcp_page_name': page_name,
'webmcp_contact_url': request.build_absolute_uri(reverse('contact')),
'webmcp_recaptcha_required': not settings.DEBUG,
'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),
}
+2 -1
View File
@@ -7,7 +7,8 @@ class FormWithCaptcha(forms.Form):
captcha = ReCaptchaField(
widget=ReCaptchaV3(
attrs={
'required_score':0.85,
'required_score': 0.85,
'form': 'contact-form',
}
),
public_key=settings.RECAPTCHA_PUBLIC_KEY,
+164
View File
@@ -0,0 +1,164 @@
"""Machine-readable site discovery endpoints for crawlers and AI agents."""
from django.http import HttpResponse
from django.template.loader import render_to_string
from django.urls import reverse
# Public marketing pages included in sitemap and llms.txt.
# Tuple: (url_name, title, changefreq, priority, summary)
PUBLIC_PAGE_ENTRIES = (
(
"public_index",
"Home",
"weekly",
"1.0",
"Company homepage with an overview of AI ML Operations services.",
),
(
"forward_deployed",
"Forward-Deployed AI",
"monthly",
"0.9",
"Embedded AI engineering — we work inside your environment to build production systems.",
),
(
"bot",
"AI Agents",
"monthly",
"0.9",
"Custom AI agents and agentic workflows tailored to your operational bottlenecks.",
),
(
"ml_model",
"ML Models",
"monthly",
"0.8",
"Machine learning model development, training, and deployment for production use.",
),
(
"chat",
"Secure AI Chat",
"monthly",
"0.8",
"Private, hosted AI chat deployments with enterprise-grade security.",
),
(
"ai_sensor",
"AI Sensor Algorithms",
"monthly",
"0.7",
"Computer vision and sensor-fusion algorithms for real-world sensing applications.",
),
(
"ai_education",
"AI Education",
"monthly",
"0.7",
"Hands-on AI training and workshops for teams adopting agentic workflows.",
),
(
"computers",
"Computer Builds",
"monthly",
"0.7",
"Custom workstation and server builds optimized for AI and ML workloads.",
),
(
"file_hosting",
"File Hosting",
"monthly",
"0.7",
"Managed file hosting and storage for teams that need reliable data access.",
),
(
"web_design",
"Web Design and Hosting",
"monthly",
"0.8",
"Web design, development, and managed hosting for business sites and apps.",
),
(
"contact",
"Contact",
"monthly",
"0.9",
"Contact form to inquire about forward-deployed AI engineering services.",
),
(
"terms_of_service",
"Terms of Service and Privacy",
"yearly",
"0.3",
"Terms of service and privacy policy for AI ML Operations, LLC.",
),
)
SERVICE_URL_NAMES = frozenset({
"forward_deployed",
"bot",
"ml_model",
"chat",
"ai_sensor",
"ai_education",
"computers",
"file_hosting",
"web_design",
})
def get_service_entries():
"""Return service page metadata for WebMCP navigation tools."""
return [
{
"slug": url_name,
"name": title,
"summary": summary,
}
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
if url_name in SERVICE_URL_NAMES
]
def _absolute_url(request, url_name):
return request.build_absolute_uri(reverse(url_name))
def robots_txt(request):
sitemap_url = _absolute_url(request, "sitemap_xml")
content = render_to_string(
"public/robots.txt",
{"sitemap_url": sitemap_url},
)
return HttpResponse(content, content_type="text/plain; charset=utf-8")
def sitemap_xml(request):
pages = [
{
"loc": _absolute_url(request, url_name),
"changefreq": changefreq,
"priority": priority,
}
for url_name, _title, changefreq, priority, _summary in PUBLIC_PAGE_ENTRIES
]
content = render_to_string("public/sitemap.xml", {"pages": pages})
return HttpResponse(content, content_type="application/xml; charset=utf-8")
def llms_txt(request):
pages = [
{
"title": title,
"url": _absolute_url(request, url_name),
}
for url_name, title, _changefreq, _priority, _summary in PUBLIC_PAGE_ENTRIES
]
content = render_to_string(
"public/llms.txt",
{
"site_url": request.build_absolute_uri("/"),
"contact_url": _absolute_url(request, "contact"),
"pages": pages,
},
)
return HttpResponse(content, content_type="text/plain; charset=utf-8")
@@ -0,0 +1,880 @@
:root {
--bg-color: #0a0a0a;
--surface-color: #1a1a1a;
--primary-color: #00f3ff;
/* Neon Cyan */
--secondary-color: #bc13fe;
/* Neon Purple */
--text-color: #e0e0e0;
--text-muted: #a0a0a0;
--font-main: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--transition-speed: 0.3s;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
font-family: var(--font-main);
line-height: 1.6;
overflow-x: hidden;
text-rendering: optimizeLegibility;
}
a {
color: var(--primary-color);
text-decoration: none;
transition: color var(--transition-speed);
}
a:hover {
color: var(--secondary-color);
}
/* Navigation */
nav {
background-color: rgba(10, 10, 10, 0.9);
backdrop-filter: blur(10px);
position: fixed;
top: 0;
width: 100%;
z-index: 1000;
padding: 1rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.brand-logo {
font-size: 1.5rem;
font-weight: 700;
color: white;
text-transform: uppercase;
letter-spacing: 1px;
}
.nav-links {
display: flex;
gap: 2rem;
list-style: none;
}
.nav-links a {
color: var(--text-color);
font-weight: 500;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.nav-links a:hover,
.nav-links a.active {
color: var(--primary-color);
}
/* Hero Section */
.hero-section {
position: relative;
height: 100vh;
min-height: 520px;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.hero-section--compact {
height: 40vh;
min-height: 300px;
}
#hero-canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
.hero-content {
position: relative;
z-index: 1;
text-align: center;
padding: 0 1rem;
min-height: 12rem;
}
.hero-title {
font-size: clamp(2.5rem, 8vw, 4rem);
font-weight: 800;
line-height: 1.1;
min-height: 1.1em;
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
margin-bottom: 1rem;
text-shadow: 0 0 20px rgba(0, 243, 255, 0.3);
}
.hero-subtitle {
font-size: 1.5rem;
color: var(--text-muted);
max-width: 600px;
margin: 0 auto;
}
/* Sections */
.section {
padding: 5rem 2rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.section-title {
font-size: 2.5rem;
font-weight: 700;
margin-bottom: 3rem;
text-align: center;
color: white;
}
.section-title::after {
content: '';
display: block;
width: 60px;
height: 4px;
background: var(--primary-color);
margin: 1rem auto 0;
border-radius: 2px;
}
/* Contact Page */
.contact-grid {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 4rem;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
/* Cards */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 2rem;
}
.card {
background: var(--surface-color);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 2rem;
transition: transform var(--transition-speed), box-shadow var(--transition-speed);
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
border-color: var(--primary-color);
}
.card-title {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: 1rem;
color: white;
display: block;
}
.card-text {
color: var(--text-muted);
font-size: 0.95rem;
}
/* Footer */
.footer {
background: var(--surface-color);
padding: 3rem 2rem;
text-align: center;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.footer-text {
color: var(--text-muted);
font-size: 0.9rem;
}
/* Mobile Nav */
.mobile-menu-btn {
display: none;
background: none;
border: none;
color: white;
font-size: 1.5rem;
cursor: pointer;
}
@media (max-width: 768px) {
.hero-title {
font-size: 2.5rem;
}
.nav-links {
display: none;
flex-direction: column;
position: absolute;
top: 100%;
left: 0;
width: 100%;
background-color: rgba(10, 10, 10, 0.95);
backdrop-filter: blur(10px);
padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.nav-links.active {
display: flex;
}
.nav-links li {
margin: 1rem 0;
text-align: center;
}
.mobile-menu-btn {
display: block;
}
/* Mobile Dropdown adjustments */
.dropdown-content {
position: static;
background: transparent;
box-shadow: none;
border: none;
min-width: auto;
padding-top: 0.5rem;
}
.dropdown-content a {
padding: 8px;
font-size: 0.85rem;
color: var(--text-muted);
}
.contact-grid {
grid-template-columns: 1fr;
gap: 2rem;
}
.form-row {
grid-template-columns: 1fr;
}
}
/* Forms */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
color: var(--text-color);
font-weight: 500;
font-size: 0.95rem;
}
.contact-info-list {
list-style: none;
}
.contact-info-item {
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 1rem;
}
.contact-info-icon {
color: var(--primary-color);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.form-control {
width: 100%;
padding: 1rem;
background: var(--bg-color);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: white;
font-family: var(--font-main);
font-size: 1rem;
transition: border-color var(--transition-speed);
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
}
.btn {
display: inline-block;
padding: 1rem 2rem;
background: var(--primary-color);
color: black;
font-weight: 700;
border: none;
border-radius: 30px;
cursor: pointer;
text-transform: uppercase;
transition: transform var(--transition-speed), box-shadow var(--transition-speed);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 243, 255, 0.3);
}
.alert {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.alert-success {
background: rgba(0, 255, 0, 0.1);
border: 1px solid rgba(0, 255, 0, 0.3);
color: #00ff00;
}
.alert-danger {
background: rgba(255, 0, 0, 0.1);
border: 1px solid rgba(255, 0, 0, 0.3);
color: #ff0000;
}
/* Dropdown */
.dropdown {
position: relative;
display: inline-block;
}
.dropdown-content {
display: none;
position: absolute;
background-color: var(--surface-color);
min-width: 200px;
box-shadow: 0 8px 16px 0 rgba(0, 0, 0, 0.2);
z-index: 1001;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
top: 100%;
left: 0;
padding: 0.5rem 0;
}
.dropdown-content li {
list-style: none;
}
.dropdown-content a {
color: var(--text-color);
padding: 12px 16px;
text-decoration: none;
display: block;
transition: background-color var(--transition-speed);
}
.dropdown-content a:hover {
background-color: rgba(255, 255, 255, 0.05);
color: var(--primary-color);
}
.dropdown:hover .dropdown-content {
display: block;
}
.dropdown.dropdown-open .dropdown-content {
display: block;
}
.nav-dropdown-trigger {
background: none;
border: none;
cursor: pointer;
font: inherit;
color: var(--text-color);
font-weight: 500;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 0;
}
.nav-dropdown-trigger:hover,
.nav-dropdown-trigger.active {
color: var(--primary-color);
}
.dropdown .nav-dropdown-trigger::after {
content: ' ▼';
font-size: 0.7em;
margin-left: 5px;
}
/* Profile Dropdown */
.profile-icon-link {
display: flex;
align-items: center;
padding: 0.25rem;
background: none;
border: none;
cursor: pointer;
font: inherit;
}
.profile-icon-link::after {
display: none !important;
}
/* Trusted-by marquee */
.trusted-by-section {
padding: 2.5rem 0;
background: var(--bg-color);
}
.trusted-by-label {
text-align: center;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.12em;
font-size: 0.8rem;
margin-bottom: 1.25rem;
}
.trusted-by-marquee {
overflow: hidden;
min-height: 48px;
}
.trusted-by-marquee-row {
display: flex;
width: max-content;
}
.trusted-by-track {
display: flex;
align-items: center;
gap: 3rem;
padding-right: 3rem;
min-height: 48px;
}
.trusted-by-logo img {
display: block;
height: 48px;
width: auto;
max-width: 320px;
}
@media (prefers-reduced-motion: no-preference) {
.trusted-by-marquee-row {
animation: trusted-by-scroll 30s linear infinite;
}
}
@keyframes trusted-by-scroll {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
.profile-icon {
width: 28px;
height: 28px;
color: var(--text-color);
transition: color var(--transition-speed), filter var(--transition-speed);
}
.profile-icon-link:hover .profile-icon {
color: var(--primary-color);
filter: drop-shadow(0 0 6px rgba(0, 243, 255, 0.5));
}
.profile-dropdown-content {
right: 0;
left: auto;
min-width: 180px;
}
.profile-name-item {
padding: 12px 16px;
color: var(--primary-color);
font-weight: 600;
font-size: 0.9rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
text-transform: none;
letter-spacing: 0;
list-style: none;
}
.profile-logout-btn {
width: 100%;
background: none;
border: none;
color: #ff4444;
padding: 12px 16px;
text-align: left;
font-family: var(--font-main);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.5px;
transition: background-color var(--transition-speed);
}
.profile-logout-btn:hover {
background-color: rgba(255, 68, 68, 0.1);
}
.text-cyber-cyan {
--tw-text-opacity: 1;
color: rgb(0 243 255 / var(--tw-text-opacity, 1));
}
/* Product Hero */
.product-hero {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4rem;
align-items: center;
min-height: 80vh;
padding: 8rem 2rem 4rem;
max-width: 1400px;
margin: 0 auto;
}
.product-hero-content {
text-align: left;
}
.product-hero-title {
font-size: 4rem;
font-weight: 800;
line-height: 1.1;
margin-bottom: 1.5rem;
background: linear-gradient(45deg, #fff, var(--text-muted));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.product-hero-subtitle {
font-size: 1.25rem;
color: var(--text-muted);
margin-bottom: 2.5rem;
line-height: 1.6;
}
.hero-image-container {
position: relative;
perspective: 1000px;
}
.hero-image {
width: 100%;
border-radius: 12px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
transform: rotateY(-5deg) rotateX(2deg);
transition: transform 0.5s ease;
}
.hero-image:hover {
transform: rotateY(0) rotateX(0);
}
@media (max-width: 968px) {
.product-hero {
grid-template-columns: 1fr;
text-align: center;
padding-top: 6rem;
gap: 3rem;
}
.product-hero-content {
text-align: center;
}
.product-hero-title {
font-size: 3rem;
}
.hero-image {
transform: none;
}
}
/* Table Styles */
.table {
width: 100%;
margin-bottom: 1rem;
color: var(--text-color);
border-collapse: collapse;
}
.table th,
.table td {
padding: 1rem;
vertical-align: top;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.table thead th {
vertical-align: bottom;
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
color: var(--primary-color);
text-align: left;
font-weight: 600;
}
.table-responsive {
display: block;
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
/* Django Form Styling defaults */
input[type="text"],
input[type="number"],
input[type="email"],
input[type="password"],
input[type="date"],
input[type="time"],
input[type="datetime-local"],
select,
textarea {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-color);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
color: white;
font-family: var(--font-main);
font-size: 1rem;
margin-bottom: 1rem;
transition: border-color var(--transition-speed);
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--primary-color);
}
.helptext {
color: var(--text-muted);
font-size: 0.85rem;
display: block;
margin-bottom: 1rem;
margin-top: -0.5rem;
}
/* Footer links */
.footer-links {
margin: 0.5rem 0;
font-size: 0.9rem;
}
.footer-links a {
color: var(--text-muted);
}
.footer-links a:hover {
color: var(--primary-color);
}
.footer-link-btn {
background: none;
border: none;
padding: 0;
color: var(--text-muted);
font: inherit;
font-size: 0.9rem;
cursor: pointer;
text-decoration: none;
}
.footer-link-btn:hover {
color: var(--primary-color);
}
.footer-separator {
color: var(--text-muted);
margin: 0 0.5rem;
}
/* Legal pages */
.legal-page {
max-width: 820px;
}
.legal-updated {
text-align: center;
color: var(--text-muted);
margin-bottom: 2rem;
}
.legal-content h2 {
color: var(--primary-color);
font-size: 1.25rem;
margin: 2rem 0 0.75rem;
}
.legal-content h3 {
color: var(--text-color);
font-size: 1.05rem;
margin: 1.5rem 0 0.5rem;
}
.legal-content p,
.legal-content li {
color: var(--text-muted);
margin-bottom: 1rem;
}
.legal-content ul {
margin: 0 0 1rem 1.5rem;
}
.contact-captcha-group {
margin-top: 1rem;
}
.contact-captcha-group + .btn {
margin-top: 1rem;
}
/* Cookie consent banner */
.cookie-consent-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 1100;
padding: 1rem;
background: rgba(26, 26, 26, 0.98);
border-top: 1px solid rgba(0, 243, 255, 0.25);
backdrop-filter: blur(10px);
}
.cookie-consent-content {
max-width: 960px;
margin: 0 auto;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.cookie-consent-text {
flex: 1 1 420px;
color: var(--text-muted);
font-size: 0.95rem;
margin: 0;
}
.cookie-consent-text-short {
display: none;
}
.cookie-consent-actions {
display: flex;
gap: 0.75rem;
flex-shrink: 0;
}
.btn-outline {
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.25);
color: var(--text-color);
}
.btn-outline:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
@media (max-width: 640px) {
.cookie-consent-banner {
padding: 0.625rem 0.75rem;
padding-bottom: max(0.625rem, env(safe-area-inset-bottom));
}
.cookie-consent-content {
flex-direction: column;
align-items: stretch;
gap: 0.625rem;
}
.cookie-consent-text-short {
display: inline;
}
.cookie-consent-text-full {
display: none;
}
.cookie-consent-text {
flex: none;
font-size: 0.8125rem;
line-height: 1.35;
}
.cookie-consent-actions {
width: 100%;
gap: 0.5rem;
}
.cookie-consent-actions .btn {
flex: 1;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
border-radius: 6px;
text-transform: none;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 843 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Some files were not shown because too many files have changed in this diff Show More