Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eed8852897 | ||
|
|
c97bd16445 | ||
|
|
8ccf17655d | ||
|
|
04d842d799 | ||
|
|
ae0f8a8bc5 | ||
|
|
16dfb3faae | ||
|
|
a5fa08d4a0 | ||
|
|
9a383c0ee9 | ||
|
|
6f97f6084d | ||
|
|
426cc82f04 | ||
|
|
7dd5ec3be1 | ||
|
|
2fb5204614 | ||
|
|
bb5aa6de82 | ||
|
|
7c9aba7e6c | ||
|
|
b5ab8eb512 | ||
|
|
846f1b8a37 | ||
|
|
c8574d76d4 | ||
|
|
63edf8c64f | ||
|
|
0f2b255b59 | ||
|
|
9c7e02e41e | ||
|
|
6e7bd25e29 | ||
|
|
7e448cf61a | ||
|
|
afc197ecdf | ||
|
|
e625dc30ad | ||
|
|
06ac8d6eca | ||
|
|
8cd3aa5f84 | ||
|
|
c88e344198 | ||
|
|
6fa4fc1f7c | ||
|
|
2d9b987d03 | ||
|
|
7d079607cb | ||
|
|
9b99ac92ee | ||
|
|
9979c5bb9c | ||
|
|
75c6bf2a53 | ||
|
|
68463c5013 | ||
|
|
150e240363 | ||
|
|
1fe85321e0 | ||
|
|
40cd595e90 | ||
|
|
1047d262c8 |
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
**/__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
db.sqlite3
|
||||||
|
.env
|
||||||
|
htmlcov/
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
*.log
|
||||||
|
staticfiles/
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 }}"
|
||||||
@@ -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
|
||||||
@@ -168,3 +168,4 @@ cython_debug/
|
|||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
|
.runner
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
repos:
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 25.1.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
@@ -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"]
|
||||||
@@ -1,3 +1,58 @@
|
|||||||
# company_site
|
# 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>`.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -20,6 +20,9 @@ from django.urls import include, path
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("public/", include("public.urls")),
|
path("public/", include("public.urls")),
|
||||||
|
path("", include("public.urls")),
|
||||||
path("financial/", include("financial.urls")),
|
path("financial/", include("financial.urls")),
|
||||||
|
path("planning/", include("planning.urls")),
|
||||||
|
path("accounts/", include("django.contrib.auth.urls")),
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -1,8 +1,28 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Contract
|
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile
|
||||||
# Register your models here.
|
|
||||||
|
|
||||||
class ContractAdmin(admin.ModelAdmin):
|
class ContractAdmin(admin.ModelAdmin):
|
||||||
pass
|
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(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)
|
||||||
|
|||||||
@@ -4,3 +4,6 @@ from django.apps import AppConfig
|
|||||||
class FinancialConfig(AppConfig):
|
class FinancialConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'financial'
|
name = 'financial'
|
||||||
|
|
||||||
|
def ready(self):
|
||||||
|
import financial.signals # noqa: F401
|
||||||
|
|||||||
@@ -1,20 +1,101 @@
|
|||||||
|
import datetime
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
from .models import Employee, Contract, ChargeNumber
|
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 EmployeeForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Employee
|
model = Employee
|
||||||
# TODO: fix slary to be salary
|
fields = ["user", "manager", "primaryAddress", "workAddress", "phoneNumber", "slary"]
|
||||||
fields = ["primaryAddress","workAddress", "slary"]
|
|
||||||
|
class UserProfileForm(ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = UserProfile
|
||||||
|
fields = ["user_type"]
|
||||||
|
|
||||||
|
class AdminUserTypeForm(forms.Form):
|
||||||
|
user = forms.ModelChoiceField(queryset=User.objects.order_by("username"))
|
||||||
|
user_type = forms.ChoiceField(choices=UserProfile.UserType.choices)
|
||||||
|
|
||||||
class ContractForm(ModelForm):
|
class ContractForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Contract
|
model = Contract
|
||||||
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 ChargeNumberForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ChargeNumber
|
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),
|
||||||
|
]
|
||||||
@@ -4,27 +4,40 @@ from phonenumber_field.modelfields import PhoneNumberField
|
|||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django_enum import EnumField
|
from django_enum import EnumField
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
from django.db.models import Sum
|
||||||
import datetime
|
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
|
# Abstract Model Classes
|
||||||
class TimeMixin(models.Model):
|
class TimeMixin(models.Model):
|
||||||
|
|
||||||
created = models.DateTimeField(default=timezone.now)
|
created = models.DateTimeField(default=timezone.now)
|
||||||
last_modified = models.DateTimeField(default=timezone.now)
|
last_modified = models.DateTimeField(default=timezone.now)
|
||||||
created_by = models.ForeignKey
|
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='+')
|
||||||
last_modified_BY = models.ForeignKey
|
last_modified_BY = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='+')
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
class IdMixin(models.Model):
|
class IdMixin(models.Model):
|
||||||
slug = models.SlugField()
|
slug = models.SlugField(blank=True, unique=True, null=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.slug is None:
|
if not self.slug:
|
||||||
self.slug = slugify(datetime.datetime.now().time())
|
self.slug = slugify(datetime.datetime.now().time())
|
||||||
super(IdMixin, self).save(*args, **kwargs)
|
super(IdMixin, self).save(*args, **kwargs)
|
||||||
|
|
||||||
@@ -32,6 +45,7 @@ class IdMixin(models.Model):
|
|||||||
class Contract(IdMixin, TimeMixin):
|
class Contract(IdMixin, TimeMixin):
|
||||||
class ContractTypeEnum(models.TextChoices):
|
class ContractTypeEnum(models.TextChoices):
|
||||||
FIRM_FIX_PRICED = "FFP", "FIRM_FIX_PRICED"
|
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"
|
MAX_NUM_CONRACT_TYPES = "MAX", "MAX_NUM_CONTRACT_TYPES"
|
||||||
|
|
||||||
contract_type = EnumField(ContractTypeEnum)
|
contract_type = EnumField(ContractTypeEnum)
|
||||||
@@ -40,12 +54,134 @@ class Contract(IdMixin, TimeMixin):
|
|||||||
proposed_amount = models.FloatField(default=0.0)
|
proposed_amount = models.FloatField(default=0.0)
|
||||||
baseline_amount = models.FloatField(default=0.0)
|
baseline_amount = models.FloatField(default=0.0)
|
||||||
funded_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_start = models.DateField(null=True, blank=True, default=None)
|
||||||
baseline_end = 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)
|
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 ChargeNumber(IdMixin, TimeMixin):
|
||||||
class ChargeNumberTypeEnum(models.TextChoices):
|
class ChargeNumberTypeEnum(models.TextChoices):
|
||||||
@@ -56,14 +192,49 @@ class ChargeNumber(IdMixin, TimeMixin):
|
|||||||
MAX_NUM_TASK_TYPES = "MAX", "MAX_NUM_TASK_TYPES"
|
MAX_NUM_TASK_TYPES = "MAX", "MAX_NUM_TASK_TYPES"
|
||||||
|
|
||||||
charge_number_type = EnumField(ChargeNumberTypeEnum)
|
charge_number_type = EnumField(ChargeNumberTypeEnum)
|
||||||
|
name = models.CharField(max_length=100, blank=True, null=True)
|
||||||
|
|
||||||
contract = models.ForeignKey(Contract, on_delete=models.CASCADE)
|
contract = models.ForeignKey(Contract, on_delete=models.CASCADE)
|
||||||
amount = models.FloatField(default=0.0)
|
amount = models.FloatField(default=0.0)
|
||||||
|
budget_hours = models.FloatField(default=0.0)
|
||||||
percent_complete = models.FloatField(default=0.0)
|
percent_complete = models.FloatField(default=0.0)
|
||||||
# TODO: add validator to make sure the range is 0.0 - 100
|
# TODO: add validator to make sure the range is 0.0 - 100
|
||||||
start_date = models.DateField(null=True, blank=True, default = None)
|
start_date = models.DateField(null=True, blank=True, default = None)
|
||||||
end_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):
|
class AddressModel(models.Model):
|
||||||
address_1 = models.CharField(max_length=128)
|
address_1 = models.CharField(max_length=128)
|
||||||
address_2 = models.CharField(max_length=128, blank=True)
|
address_2 = models.CharField(max_length=128, blank=True)
|
||||||
@@ -72,15 +243,43 @@ class AddressModel(models.Model):
|
|||||||
state = models.CharField(max_length=2)
|
state = models.CharField(max_length=2)
|
||||||
zip_code = models.CharField(max_length=5)
|
zip_code = models.CharField(max_length=5)
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfile(models.Model):
|
||||||
|
class UserType(models.TextChoices):
|
||||||
|
EMPLOYEE = "employee", "Employee"
|
||||||
|
CLIENT = "client", "Client"
|
||||||
|
|
||||||
|
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
||||||
|
user_type = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=UserType.choices,
|
||||||
|
default=UserType.CLIENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user} ({self.get_user_type_display()})"
|
||||||
|
|
||||||
|
def is_employee(self):
|
||||||
|
return self.user_type == self.UserType.EMPLOYEE
|
||||||
|
|
||||||
|
def is_client(self):
|
||||||
|
return self.user_type == self.UserType.CLIENT
|
||||||
|
|
||||||
|
|
||||||
class Employee(IdMixin, TimeMixin):
|
class Employee(IdMixin, TimeMixin):
|
||||||
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee")
|
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||||
primaryAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="primary_address_employee")
|
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")
|
workAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="work_address_employee", null=True, blank=True)
|
||||||
phoneNumber = PhoneNumberField(null=False, blank=False, unique=True)
|
phoneNumber = PhoneNumberField(null=True, blank=True, unique=True)
|
||||||
slary= models.FloatField(default=0.0)
|
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
|
# TODO: roles, jpbTitles
|
||||||
|
|
||||||
|
|
||||||
@@ -97,9 +296,36 @@ class TimeCard(IdMixin, TimeMixin):
|
|||||||
class TimeCardCell(IdMixin, TimeMixin):
|
class TimeCardCell(IdMixin, TimeMixin):
|
||||||
timeCard = models.ForeignKey(TimeCard, on_delete=models.CASCADE)
|
timeCard = models.ForeignKey(TimeCard, on_delete=models.CASCADE)
|
||||||
date = models.DateField(null=True, default = None)
|
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)
|
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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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-->
|
{% extends "base.html" %}
|
||||||
|
|
||||||
<!doctype html>
|
|
||||||
{% load static %}
|
{% 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 %}
|
{% if is_new %}
|
||||||
<h1>New Contract</h1>
|
<h1 class="section-title" style="text-align: left;">New Contract</h1>
|
||||||
<form method="post" action='{% url "new_contract" %}' >
|
<div class="card" style="max-width: 600px;">
|
||||||
|
<form method="post" action='{% url "new_contract" %}'>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form }}
|
{{ form.as_p }}
|
||||||
<button type=""button" value="submit">Create</button>
|
<button type="submit" class="btn" style="margin-top: 1rem;">Create</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<h1> {{ contract.name }}</h1>
|
<h1 class="section-title" style="text-align: left;">{{ contract.name }}</h1>
|
||||||
<form method="post" action='{% url "contract_detail" contract.slug %}' >
|
<div class="card" style="max-width: 600px;">
|
||||||
|
<form method="post" action='{% url "contract_detail" contract.slug %}'>
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form }}
|
{{ form.as_p }}
|
||||||
<button type=""button" value="submit">Update</button>
|
<button type="submit" class="btn" style="margin-top: 1rem;">Update</button>
|
||||||
</form>
|
</form>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% 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 %}
|
<h2 class="section-title" style="text-align: left; font-size: 2rem;">Charge Numbers</h2>
|
||||||
{% else %}
|
{% if charge_numbers %}
|
||||||
<h2> Charge Numbers</h2>
|
{% if mermaid_gantt %}
|
||||||
{% if charge_numbes %}
|
<div class="card charge-gantt-card" style="margin-bottom: 2rem; background: var(--surface-color);">
|
||||||
<p> put charge number table here</p>
|
<h3 class="evm-chart-title" style="margin-bottom: 1rem;">Timeline</h3>
|
||||||
<p> Create a new charge number</p>
|
<div class="charge-gantt-scroll">
|
||||||
{{ charge_number_form }}
|
<div class="mermaid">
|
||||||
{% else %}
|
{{ mermaid_gantt|safe }}
|
||||||
<p> There are no charge numbers for this contract</p>
|
</div>
|
||||||
<form method="post" action='{% url "new_charge_number" %}' >
|
</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 %}
|
{% csrf_token %}
|
||||||
{{ charge_number_form }}
|
{{ charge_number_form.as_p }}
|
||||||
<button type=""button" value="submit">Update</button>
|
<button type="submit" class="btn" style="margin-top: 1rem;">Add Charge Number</button>
|
||||||
</form>
|
</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 %}
|
||||||
|
|
||||||
|
{% 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-->
|
{% extends "base.html" %}
|
||||||
|
|
||||||
<!doctype html>
|
|
||||||
{% load static %}
|
{% 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 %}
|
{% if contracts %}
|
||||||
<table>
|
<table class="table" style="margin-bottom: 2rem;">
|
||||||
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Identifier</th>
|
<th>Identifier</th>
|
||||||
<th>Type</th>
|
<th>Type</th>
|
||||||
|
<th>% Complete</th>
|
||||||
<th>Start Date</th>
|
<th>Start Date</th>
|
||||||
<th>End Date</th>
|
<th>End Date</th>
|
||||||
<th>Proposed Amount</th>
|
<th>Proposed Amount</th>
|
||||||
<th>Baseline Amount</th>
|
<th>Budget Hours</th>
|
||||||
<th>Funded Amount</th>
|
<th>Hours Charged</th>
|
||||||
|
<th>Money Spent</th>
|
||||||
|
<th>Money Remaining</th>
|
||||||
|
<th>Projected End Date</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
{% for contract in contracts %}
|
{% for contract in contracts %}
|
||||||
<tr>
|
<tr>
|
||||||
<td> <a href="{% url 'contract_detail' contract.slug %}">{{ contract.name }}</a></td>
|
<td><a href="{% url 'contract_detail' contract.slug %}">{{ contract.name }}</a></td>
|
||||||
<td> {{ contract.slug }}</td>
|
<td>{{ contract.slug }}</td>
|
||||||
<td> {{ contract.contract_type }}</td>
|
<td>{{ contract.contract_type }}</td>
|
||||||
<td> {{ contract.baseline_start }}</td>
|
<td>
|
||||||
<td> {{ contract.baseline_end }}</td>
|
<span class="badge" style="background: rgba(0, 243, 255, 0.1); border: 1px solid var(--primary-color); color: var(--primary-color);">
|
||||||
<td> {{ contract.proposed_amount }}</td>
|
{{ contract.ticket_percent_complete|floatformat:0 }}%
|
||||||
<td> {{ contract.baseline_amount }}</td>
|
</span>
|
||||||
<td> {{ contract.funded_amount }}</td>
|
</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>
|
</tr>
|
||||||
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
</table>
|
</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 %}
|
{% 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 %}
|
{% 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>
|
{% block title %}Accounting Dashboard{% endblock %}
|
||||||
</div>
|
|
||||||
<main class="main-content position-relative max-height-vh-100 h-100 border-radius-lg">
|
{% block content %}
|
||||||
<nav class="navbar navbar-main navbar-expand-lg px-0 mx-4 shadow-none border-radius-xl" id="navbarBlur" data-scroll="true">
|
<div class="section">
|
||||||
<div class="container-fluid py-1 px-3">
|
<div class="container">
|
||||||
<nav aria-label="breadcrumb">
|
<h1 class="section-title">Dashboard</h1>
|
||||||
<h6>Dashboard</h6>
|
|
||||||
</nav>
|
<div class="card-grid" style="margin-bottom: 3rem;">
|
||||||
</div>
|
{% if is_financial_admin %}
|
||||||
</nav>
|
<a href="{% url 'contracts' %}" class="card"
|
||||||
<div class="container-fluid py-4">
|
data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
|
||||||
<div class="row">
|
<span class="card-title">View Contracts</span>
|
||||||
<div class="col-xl-3 col-sm-4 mb-xl-o mb-4">
|
<p class="card-text">Overview of all active and past contracts and their scopes.</p>
|
||||||
<div class="card">
|
</a>
|
||||||
<div class="card-header p-3 pt-2">
|
<a href="{% url 'new_contract' %}" class="card"
|
||||||
<a href="{% url 'contracts' %}">
|
data-tianji-event="financial_nav" data-tianji-event-destination="new_contract">
|
||||||
Contracts
|
<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>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div class="card-body">
|
<h2 class="section-title" style="font-size: 2rem; margin-bottom: 2rem;">Contracts Overview</h2>
|
||||||
<p> put picture here</p>
|
<div class="table-responsive">
|
||||||
</div>
|
{% if contracts %}
|
||||||
</div>
|
<table class="table">
|
||||||
</div>
|
<thead>
|
||||||
{% if is_worker %}
|
<tr>
|
||||||
<div class="col-xl-3 col-sm-4 mb-xl-o mb-4">
|
<th>Contract Name</th>
|
||||||
<div class="card">
|
<th>Budget Hours</th>
|
||||||
<div class="card-header p-3 pt-2">
|
<th>Logged Hours</th>
|
||||||
<a href="{% url 'profile' %}">
|
</tr>
|
||||||
Profile
|
</thead>
|
||||||
</a>
|
<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>
|
||||||
|
|
||||||
</div class="card-body">
|
<hr style="margin: 40px 0; border: 0; border-top: 1px solid rgba(255,255,255,0.1);">
|
||||||
<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>
|
|
||||||
|
|
||||||
</div class="card-body">
|
<h2 class="section-title" style="font-size: 2rem; margin-bottom: 2rem;">Employee Hours per Contract</h2>
|
||||||
<p> put picture here</p>
|
<div class="table-responsive">
|
||||||
</div>
|
{% if employee_data %}
|
||||||
</div>
|
<table class="table">
|
||||||
</div>
|
<thead>
|
||||||
{% endif %}
|
<tr>
|
||||||
{% if is_manager %}
|
<th>Employee Name</th>
|
||||||
<div class="col-xl-3 col-sm-4 mb-xl-o mb-4">
|
{% for c in contracts %}
|
||||||
<div class="card">
|
<th>{{ c.name }}</th>
|
||||||
<div class="card-header p-3 pt-2">
|
{% endfor %}
|
||||||
<a href="{% url 'Timeapproval' %}">
|
</tr>
|
||||||
Time Approval
|
</thead>
|
||||||
</a>
|
<tbody>
|
||||||
</div>
|
{% for row in employee_data %}
|
||||||
<div class="card-body">
|
<tr>
|
||||||
<p> put picture here</p>
|
<td>{{ row.employee }}</td>
|
||||||
</div>
|
{% for ch in row.contract_hours %}
|
||||||
</div>
|
<td>{{ ch.hours }}</td>
|
||||||
</div>
|
{% endfor %}
|
||||||
{% endif %}
|
</tr>
|
||||||
{% if is_procurment_officer %}
|
{% endfor %}
|
||||||
<div class="col-xl-3 col-sm-4 mb-xl-o mb-4">
|
</tbody>
|
||||||
<div class="card">
|
</table>
|
||||||
<div class="card-header p-3 pt-2">
|
{% else %}
|
||||||
</div>
|
<p style="color: var(--text-muted);">No employees available.</p>
|
||||||
<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>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</div>
|
||||||
<h1>Accouting</h1>
|
{% endblock %}
|
||||||
</body>
|
|
||||||
@@ -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-->
|
{% extends "base.html" %}
|
||||||
|
|
||||||
<!doctype html>
|
|
||||||
{% load static %}
|
{% 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>
|
{% if messages %}
|
||||||
{{ form }}
|
{% for message in messages %}
|
||||||
</body>
|
<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 %}
|
||||||
@@ -1,3 +1,159 @@
|
|||||||
from django.test import TestCase
|
from django.contrib.auth.models import User
|
||||||
|
from django.test import Client, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
# Create your tests here.
|
from financial.models import (
|
||||||
|
AddressModel,
|
||||||
|
ChargeNumber,
|
||||||
|
Contract,
|
||||||
|
Employee,
|
||||||
|
TimeCard,
|
||||||
|
TimeCardCell,
|
||||||
|
UserProfile,
|
||||||
|
set_user_type,
|
||||||
|
)
|
||||||
|
from financial.permissions import get_employees, is_client_user, is_employee_user
|
||||||
|
|
||||||
|
|
||||||
|
class UserProfileSignalTests(TestCase):
|
||||||
|
def test_new_user_gets_client_profile_not_employee(self):
|
||||||
|
user = User.objects.create_user(username="newbie", password="pass")
|
||||||
|
self.assertTrue(UserProfile.objects.filter(user=user, user_type=UserProfile.UserType.CLIENT).exists())
|
||||||
|
self.assertFalse(Employee.objects.filter(user=user).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class SetUserTypeTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(username="worker", password="pass")
|
||||||
|
UserProfile.objects.filter(user=self.user).delete()
|
||||||
|
|
||||||
|
def test_set_employee_creates_employee_record(self):
|
||||||
|
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
|
||||||
|
self.assertTrue(Employee.objects.filter(user=self.user).exists())
|
||||||
|
self.assertEqual(self.user.profile.user_type, UserProfile.UserType.EMPLOYEE)
|
||||||
|
|
||||||
|
def test_set_client_removes_employee_without_time_entries(self):
|
||||||
|
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
|
||||||
|
set_user_type(self.user, UserProfile.UserType.CLIENT)
|
||||||
|
self.assertFalse(Employee.objects.filter(user=self.user).exists())
|
||||||
|
self.assertEqual(
|
||||||
|
UserProfile.objects.get(user=self.user).user_type,
|
||||||
|
UserProfile.UserType.CLIENT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_cannot_set_client_with_time_entries(self):
|
||||||
|
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
|
||||||
|
employee = Employee.objects.get(user=self.user)
|
||||||
|
contract = Contract.objects.create(
|
||||||
|
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
|
||||||
|
name="Test Contract",
|
||||||
|
)
|
||||||
|
charge = ChargeNumber.objects.create(
|
||||||
|
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
|
||||||
|
contract=contract,
|
||||||
|
)
|
||||||
|
time_card = TimeCard.objects.create(employee=employee)
|
||||||
|
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=2.0)
|
||||||
|
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
set_user_type(self.user, UserProfile.UserType.CLIENT)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeFilterTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.employee_user = User.objects.create_user(username="emp", password="pass")
|
||||||
|
self.client_user = User.objects.create_user(username="cli", password="pass")
|
||||||
|
set_user_type(self.employee_user, UserProfile.UserType.EMPLOYEE)
|
||||||
|
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
|
||||||
|
|
||||||
|
def test_get_employees_excludes_clients(self):
|
||||||
|
employees = list(get_employees())
|
||||||
|
self.assertEqual(len(employees), 1)
|
||||||
|
self.assertEqual(employees[0].user, self.employee_user)
|
||||||
|
|
||||||
|
def test_permission_helpers(self):
|
||||||
|
self.assertTrue(is_employee_user(self.employee_user))
|
||||||
|
self.assertFalse(is_employee_user(self.client_user))
|
||||||
|
self.assertTrue(is_client_user(self.client_user))
|
||||||
|
self.assertFalse(is_client_user(self.employee_user))
|
||||||
|
|
||||||
|
|
||||||
|
class FinancialAccessTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = Client()
|
||||||
|
self.admin = User.objects.create_superuser(username="admin", password="pass", email="a@test.com")
|
||||||
|
self.employee = User.objects.create_user(username="employee", password="pass")
|
||||||
|
self.client_user = User.objects.create_user(username="client", password="pass")
|
||||||
|
set_user_type(self.employee, UserProfile.UserType.EMPLOYEE)
|
||||||
|
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
|
||||||
|
|
||||||
|
contract = Contract.objects.create(
|
||||||
|
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
|
||||||
|
name="C1",
|
||||||
|
budget_hours=100,
|
||||||
|
)
|
||||||
|
charge = ChargeNumber.objects.create(
|
||||||
|
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
|
||||||
|
contract=contract,
|
||||||
|
)
|
||||||
|
emp_record = Employee.objects.get(user=self.employee)
|
||||||
|
time_card = TimeCard.objects.create(employee=emp_record)
|
||||||
|
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=4.0)
|
||||||
|
|
||||||
|
def test_client_can_view_reports_readonly(self):
|
||||||
|
self.client.login(username="client", password="pass")
|
||||||
|
response = self.client.get(reverse("client_reports"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "C1")
|
||||||
|
|
||||||
|
def test_client_cannot_log_time(self):
|
||||||
|
self.client.login(username="client", password="pass")
|
||||||
|
response = self.client.get(reverse("Timekeeping"))
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_client_can_view_time_logs_without_edit(self):
|
||||||
|
self.client.login(username="client", password="pass")
|
||||||
|
response = self.client.get(reverse("time_logs"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Read only")
|
||||||
|
self.assertNotContains(response, 'href="/financial/time_logs/')
|
||||||
|
|
||||||
|
def test_employee_can_access_timekeeping(self):
|
||||||
|
self.client.login(username="employee", password="pass")
|
||||||
|
response = self.client.get(reverse("Timekeeping"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_client_redirected_from_financial_home_to_reports(self):
|
||||||
|
self.client.login(username="client", password="pass")
|
||||||
|
response = self.client.get(reverse("financial_home"))
|
||||||
|
self.assertRedirects(response, reverse("client_reports"))
|
||||||
|
|
||||||
|
def test_admin_can_manage_users(self):
|
||||||
|
self.client.login(username="admin", password="pass")
|
||||||
|
response = self.client.get(reverse("manage_users"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
response = self.client.post(reverse("manage_users"), {
|
||||||
|
"user": self.client_user.id,
|
||||||
|
"user_type": UserProfile.UserType.EMPLOYEE,
|
||||||
|
})
|
||||||
|
self.assertRedirects(response, reverse("manage_users"))
|
||||||
|
self.client_user.refresh_from_db()
|
||||||
|
self.assertEqual(self.client_user.profile.user_type, UserProfile.UserType.EMPLOYEE)
|
||||||
|
|
||||||
|
def test_time_logs_only_lists_employees(self):
|
||||||
|
self.client.login(username="admin", password="pass")
|
||||||
|
response = self.client.get(reverse("time_logs"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "employee")
|
||||||
|
employees = response.context["employees"]
|
||||||
|
self.assertEqual(employees.count(), 1)
|
||||||
|
|
||||||
|
def test_new_user_not_in_employee_dropdown(self):
|
||||||
|
extra = User.objects.create_user(username="extra", password="pass")
|
||||||
|
set_user_type(extra, UserProfile.UserType.CLIENT)
|
||||||
|
self.client.login(username="admin", password="pass")
|
||||||
|
response = self.client.get(reverse("time_logs"))
|
||||||
|
employees = response.context["employees"]
|
||||||
|
usernames = [e.user.username for e in employees]
|
||||||
|
self.assertIn("employee", usernames)
|
||||||
|
self.assertNotIn("extra", usernames)
|
||||||
|
|||||||
@@ -3,15 +3,22 @@ from django.urls import path
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.index, name="financial_index"),
|
path("", views.financial_home, name="financial_home"),
|
||||||
|
path("dashboard", views.index, name="financial_index"),
|
||||||
path("timekeeping", views.timekeeping, name="Timekeeping"),
|
path("timekeeping", views.timekeeping, name="Timekeeping"),
|
||||||
|
path("time_logs", views.time_logs, name="time_logs"),
|
||||||
|
path("time_logs/<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("timeapproval", views.timeapproval, name="Timeapproval"),
|
||||||
path("contracts", views.contracts, name="contracts"),
|
path("contracts", views.contracts, name="contracts"),
|
||||||
path("<str:contract_slug>/contract_detail", views.contract_detail, name="contract_detail"),
|
path("<str:contract_slug>/contract_detail", views.contract_detail, name="contract_detail"),
|
||||||
path("new_contract", views.new_contract, name="new_contract"),
|
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("<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("contracts/<int:contract_id>/", views.contract_detail, name="contract"),
|
||||||
path("procurements", views.procurement, name="procurements"),
|
path("procurements", views.procurement, name="procurements"),
|
||||||
path("profile", views.profile, name="profile"),
|
path("profile", views.profile, name="profile"),
|
||||||
|
path("manage_users", views.manage_users, name="manage_users"),
|
||||||
|
path("client_reports", views.client_reports, name="client_reports"),
|
||||||
]
|
]
|
||||||
@@ -1,48 +1,185 @@
|
|||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from .forms import EmployeeForm, ContractForm, ChargeNumberForm
|
from django.contrib.auth.models import User
|
||||||
from .models import Contract
|
from django.contrib import messages
|
||||||
# Create your views here.
|
from django.utils import timezone
|
||||||
|
from django.db.models import Sum
|
||||||
|
from datetime import timedelta
|
||||||
|
import json
|
||||||
|
|
||||||
# PAGES TO CREATE
|
from .forms import (
|
||||||
# dashboard
|
EmployeeForm,
|
||||||
# log in
|
ContractForm,
|
||||||
# log out
|
ChargeNumberForm,
|
||||||
# password reset
|
TimeLogForm,
|
||||||
# employee timecard
|
NewEmployeeForm,
|
||||||
# time card approval
|
UserProfileForm,
|
||||||
# contract
|
AdminUserTypeForm,
|
||||||
# charge number
|
)
|
||||||
# user management (?)
|
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):
|
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()
|
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):
|
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':
|
if request.method == 'POST':
|
||||||
form = ContractForm(request.POST, instance=contract[0])
|
form = ContractForm(request.POST, instance=contract)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect('contracts')
|
return redirect('contracts')
|
||||||
else:
|
else:
|
||||||
form = ContractForm(instance = contract[0])
|
form = ContractForm(instance=contract)
|
||||||
charge_number_form = ChargeNumberForm()
|
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):
|
def new_contract(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = ContractForm(request.POST)
|
form = ContractForm(request.POST)
|
||||||
@@ -50,30 +187,251 @@ def new_contract(request):
|
|||||||
form.save()
|
form.save()
|
||||||
return redirect('contracts')
|
return redirect('contracts')
|
||||||
else:
|
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):
|
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):
|
def timeapproval(request):
|
||||||
return render(request, 'financial/not_created.html', {})
|
return render(request, 'financial/not_created.html', {})
|
||||||
|
|
||||||
|
|
||||||
|
@financial_admin_required
|
||||||
def chargenumber(request):
|
def chargenumber(request):
|
||||||
return render(request, 'financial/not_created.html', {})
|
return render(request, 'financial/not_created.html', {})
|
||||||
|
|
||||||
|
|
||||||
|
@financial_admin_required
|
||||||
def procurement(request):
|
def procurement(request):
|
||||||
return render(request, 'financial/procurement.html', {})
|
return render(request, 'financial/procurement.html', {})
|
||||||
|
|
||||||
|
|
||||||
|
@financial_access_required
|
||||||
def profile(request):
|
def profile(request):
|
||||||
form = EmployeeForm()
|
profile_obj, _ = UserProfile.objects.get_or_create(user=request.user)
|
||||||
return render(request, 'financial/profile.html', {'form': form})
|
can_edit_type = is_financial_admin(request.user)
|
||||||
# def contract_detail(request, contract_id):
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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;">×</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;">×</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">×</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">×</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 %}
|
||||||
@@ -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'),
|
||||||
|
]
|
||||||
@@ -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)
|
||||||
@@ -5,26 +5,32 @@ from django.shortcuts import render, get_object_or_404
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
from django.core.mail import EmailMultiAlternatives
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
|
|
||||||
# Register your models here.
|
# Register your models here.
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Contact, site=admin.site)
|
@admin.register(Contact, site=admin.site)
|
||||||
class ContactAdmin(admin.ModelAdmin):
|
class ContactAdmin(admin.ModelAdmin):
|
||||||
list_display = ("email", "name", "contacted")
|
list_display = ("email", "name", "contacted")
|
||||||
list_filter = ("email", "name", "contacted")
|
list_filter = ("email", "name", "contacted")
|
||||||
search_fields = ("email", "name")
|
search_fields = ("email", "name")
|
||||||
|
|
||||||
@admin.action(description='Send seelcted emails')
|
|
||||||
|
@admin.action(description="Send seelcted emails")
|
||||||
def send_emails(modeladmin, request, queryset):
|
def send_emails(modeladmin, request, queryset):
|
||||||
for email in queryset:
|
for email in queryset:
|
||||||
success_count: int = 0
|
success_count: int = 0
|
||||||
try:
|
try:
|
||||||
from_email="info@aimloperations.com"
|
from_email = "AI ML Operations, LLC <info@aimloperations.com>"
|
||||||
d={"title":email.subject,"content":email.body}
|
d = {"title": email.subject, "content": email.body}
|
||||||
|
|
||||||
html_content = get_template(f"emails/marketing_email.html").render(d)
|
html_content = get_template(f"emails/marketing_email.html").render(d)
|
||||||
text_content = get_template(f"emails/marketing_email.txt").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.attach_alternative(html_content, "text/html")
|
||||||
|
|
||||||
msg.send(fail_silently=False)
|
msg.send(fail_silently=False)
|
||||||
@@ -36,29 +42,26 @@ def send_emails(modeladmin, request, queryset):
|
|||||||
raise UserWarning(e)
|
raise UserWarning(e)
|
||||||
modeladmin.message_user(request, f"{success_count} emails sent successfully.")
|
modeladmin.message_user(request, f"{success_count} emails sent successfully.")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(EmailMessage, site=admin.site)
|
@admin.register(EmailMessage, site=admin.site)
|
||||||
class EmailMessageAdmin(admin.ModelAdmin):
|
class EmailMessageAdmin(admin.ModelAdmin):
|
||||||
change_form_template = 'admin/public/emailmessage/change_form.html'
|
change_form_template = "admin/public/emailmessage/change_form.html"
|
||||||
list_display = ('subject', 'recipient', 'sent')
|
list_display = ("subject", "recipient", "sent")
|
||||||
actions = [send_emails]
|
actions = [send_emails]
|
||||||
|
|
||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
urls = super().get_urls()
|
urls = super().get_urls()
|
||||||
custom_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
|
return custom_urls + urls
|
||||||
|
|
||||||
def preview_email(self, request, pk):
|
def preview_email(self, request, pk):
|
||||||
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
email_instance = get_object_or_404(EmailMessage, pk=pk)
|
||||||
context = {
|
context = {"title": email_instance.subject, "content": email_instance.body}
|
||||||
"title":email_instance.subject,
|
return TemplateResponse(request, "public/preview_email.html", context)
|
||||||
"content":email_instance.body
|
|
||||||
}
|
|
||||||
return render(
|
|
||||||
request, 'public/preview_email.html', context
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ class FormWithCaptcha(forms.Form):
|
|||||||
captcha = ReCaptchaField(
|
captcha = ReCaptchaField(
|
||||||
widget=ReCaptchaV3(
|
widget=ReCaptchaV3(
|
||||||
attrs={
|
attrs={
|
||||||
'required_score':0.85,
|
'required_score': 0.85,
|
||||||
|
'form': 'contact-form',
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
public_key=settings.RECAPTCHA_PUBLIC_KEY,
|
public_key=settings.RECAPTCHA_PUBLIC_KEY,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 636 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 210 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 3.3 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 843 KiB |
|
After Width: | Height: | Size: 3.4 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 99 KiB |