Author SHA1 Message Date
westfarn 07cba8bc7f Add unit tests ensuring /preview_email/ requires authentication.
CI / test (pull_request) Successful in 11s
Unit Tests / test (pull_request) Successful in 10s
Locks in login_required behavior for issue #16 so unauthenticated access stays redirected.
2026-07-10 12:53:17 -05:00
westfarn 8ccf17655d updateing deploy for westfarn user
Unit Tests / test (push) Successful in 11s
2026-07-08 12:26:07 -05:00
westfarn 04d842d799 Test who runs the job
Unit Tests / test (push) Successful in 10s
2026-07-08 12:21:50 -05:00
westfarn ae0f8a8bc5 Updated deploy to use act_runner keys
Deploy Company Site / test (push) Successful in 10s
Unit Tests / test (push) Successful in 10s
Deploy Company Site / docker (push) Successful in 16s
Deploy Company Site / deploy (push) Failing after 0s
2026-07-08 11:51:33 -05:00
westfarn 16dfb3faae Updated deploy to use act_runner keys
Unit Tests / test (push) Successful in 10s
2026-07-08 11:46:43 -05:00
westfarn a5fa08d4a0 update docker unittests
Unit Tests / test (push) Successful in 10s
2026-07-08 06:12:21 -05:00
westfarn 9a383c0ee9 Update workflow to use server-infra
Unit Tests / test (push) Successful in 10s
2026-07-08 06:05:11 -05:00
westfarn 6f97f6084d Adding images
Unit Tests / test (push) Successful in 11s
2026-07-07 15:28:45 -05:00
westfarn 426cc82f04 Dockerize Django app with dev/beta/prod env config and uv (#6)
Unit Tests / test (push) Successful in 10s
## Summary

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

Closes #4

## Test plan

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

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

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

## Design decisions (from issue Q&A)

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

## Test plan

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

Closes #14

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

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

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

## Test plan

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

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

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

Closes #9

## Test plan

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

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

Closes #7

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

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

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

## Test plan

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

Reviewed-on: #8
2026-07-02 11:36:54 +00:00
41 changed files with 1952 additions and 200 deletions
+17 -31
View File
@@ -1,39 +1,21 @@
name: Deploy Company Site name: Deploy Company Site
# Deploy pipeline runs only on pushes to master (never on pull requests). # Runs after Unit Tests completes on master. Direct pushes only (not PRs).
on: on:
push: workflow_run:
workflows: [Unit Tests]
types: [completed]
branches: [master] branches: [master]
jobs: jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
run: |
cd company_site
uv run python manage.py test
docker: docker:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted runs-on: self-hosted
needs: test
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
ref: ${{ gitea.event.workflow_run.head_sha }}
- name: Build Docker image - name: Build Docker image
run: docker compose build run: docker compose build
@@ -49,11 +31,15 @@ jobs:
docker compose down docker compose down
deploy: deploy:
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
runs-on: self-hosted runs-on: self-hosted
needs: [test, docker] needs: docker
env:
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
steps: steps:
- name: Checkout - name: Deploy company_site prod
uses: actions/checkout@v4 run: |
"${SERVER_INFRA_ROOT}/scripts/deploy.sh" \
- name: Deploy to live site --app company_site \
run: bash scripts/deploy.sh "${{ gitea.workspace }}" --env prod \
--ref "${{ gitea.event.workflow_run.head_sha }}"
+30
View File
@@ -0,0 +1,30 @@
name: Unit Tests
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install uv
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Install dependencies
run: uv sync --frozen
- name: Run unit tests
env:
DJANGO_ENV: dev
DJANGO_SECRET_KEY: test-secret-key
run: |
cd company_site
uv run python manage.py test
+14 -1
View File
@@ -1,5 +1,6 @@
"""Shared Django settings for all environments.""" """Shared Django settings for all environments."""
import json
import os import os
from pathlib import Path from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -22,6 +23,15 @@ def env_list(key: str, default: str = "") -> list[str]:
value = os.environ.get(key, default) value = os.environ.get(key, default)
if not value: if not value:
return [] return []
value = value.strip()
# Accept a JSON array (e.g. '["a","b"]') as well as a comma-separated list.
if value.startswith("["):
try:
parsed = json.loads(value)
except ValueError:
parsed = None
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()]
return [item.strip() for item in value.split(",") if item.strip()] return [item.strip() for item in value.split(",") if item.strip()]
@@ -67,6 +77,7 @@ SECRET_KEY = env(
DEBUG = env_bool("DJANGO_DEBUG", False) DEBUG = env_bool("DJANGO_DEBUG", False)
TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG) TIANJI_ENABLED = env_bool("TIANJI_ENABLED", not DEBUG)
WEBMCP_ENABLED = env_bool("WEBMCP_ENABLED", False)
allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*") allowed_hosts = env_list("DJANGO_ALLOWED_HOSTS", "*")
ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"] ALLOWED_HOSTS = allowed_hosts if allowed_hosts else ["*"]
@@ -111,6 +122,8 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"public.context_processors.tianji_tracking", "public.context_processors.tianji_tracking",
"public.context_processors.webmcp_context",
"public.context_processors.financial_access",
], ],
}, },
}, },
@@ -144,7 +157,7 @@ STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
STORAGES = { STORAGES = {
"staticfiles": { "staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", "BACKEND": "company_site.storage.TolerantManifestStaticFilesStorage",
}, },
} }
+32
View File
@@ -0,0 +1,32 @@
"""Custom static files storage.
WhiteNoise's manifest storage post-processes JS/CSS during ``collectstatic`` and
strictly resolves every referenced file, including ``sourceMappingURL`` comments
in vendored bundles. Some third-party assets reference ``.map`` files that are
not shipped, which makes ``collectstatic`` fail hard.
``TolerantManifestStaticFilesStorage`` leaves such unresolved references
untouched instead of raising, so a missing source map can't break the build.
"""
from whitenoise.storage import CompressedManifestStaticFilesStorage
class TolerantManifestStaticFilesStorage(CompressedManifestStaticFilesStorage):
# Don't 500 at runtime when a {% static %} reference isn't in the manifest;
# fall back to the plain name (mirrors non-manifest storage behaviour).
manifest_strict = False
def _stored_name(self, name, hashed_files):
"""Tolerate missing references during collectstatic post-processing."""
try:
return super()._stored_name(name, hashed_files)
except ValueError:
return name
def stored_name(self, name):
"""Tolerate missing manifest entries at request time."""
try:
return super().stored_name(name)
except ValueError:
return name
+56
View File
@@ -0,0 +1,56 @@
# Agentic Browsing Readiness
This site targets Google's experimental **Agentic Browsing** Lighthouse category (v13.3+), which checks whether AI agents can read, navigate, and act on public pages.
PageSpeed Insights does not yet expose this category. Run audits locally:
```bash
npx lighthouse@latest https://aimloperations.com \
--only-categories=agentic-browsing \
--chrome-flags="--enable-experimental-web-platform-features" \
--output=html --output-path=agentic-browsing-report.html
```
Test at minimum:
- `/` (homepage)
- `/contact` (primary conversion page)
## What we ship
| Check | Implementation |
|-------|----------------|
| **llms.txt** | `GET /llms.txt` — machine-readable site summary |
| **robots.txt** | `GET /robots.txt` — crawl rules + sitemap reference |
| **sitemap.xml** | `GET /sitemap.xml` — public marketing URLs |
| **Accessibility tree** | Form labels, semantic nav controls, ARIA on dialogs |
| **Layout stability** | Fixed cookie banner, reserved hero/marquee space, font fallbacks |
| **WebMCP tools** | `navigator.modelContext` tools behind `WEBMCP_ENABLED` (see [webmcp.md](webmcp.md)) |
## WebMCP (issue #9)
When `WEBMCP_ENABLED=True`:
- Navigation tools (`list_services`, `get_page_content`, `navigate_to_service`, `open_contact_with_subject`) load on all public pages
- Contact page registers `submit_contact_inquiry` via declarative form annotations (`toolname`, `tooldescription`, `toolparamdescription`); reCAPTCHA renders outside the annotated form
- Default is **disabled** (`WEBMCP_ENABLED=False`) until deliberately enabled per environment
Full tool catalog, Chrome flag setup, and reCAPTCHA notes: **[docs/webmcp.md](webmcp.md)**
## Regression checklist
Before merging public-facing template or CSS changes:
1. Contact form fields have associated `<label>` elements (not placeholder-only).
2. Nav dropdowns use `<button>` triggers with `aria-expanded` / `aria-haspopup`.
3. Cookie consent banner stays `position: fixed` (no document flow shift).
4. `GET /robots.txt`, `/sitemap.xml`, `/llms.txt` return 200.
5. With `WEBMCP_ENABLED=True`, public pages include `webmcp-config` and `webmcp-tools.js`; contact form always has `toolname`, `tooldescription`, and `toolparamdescription` on fields.
6. Re-run Lighthouse agentic-browsing on homepage and contact page (Chrome experimental flag on).
## References
- [Lighthouse Agentic Browsing audit overview](https://locomotive.agency/blog/lighthouse-agentic-browsing-audit/)
- [llms.txt proposal](https://llmstxt.org/)
- [WebMCP tool catalog](webmcp.md)
- Gitea issue #5 (foundational), issue #9 (WebMCP)
+108
View File
@@ -0,0 +1,108 @@
# WebMCP Tool Catalog
WebMCP exposes public marketing actions as named, callable tools via `navigator.modelContext` (or `document.modelContext` in newer builds). Tools register only when `WEBMCP_ENABLED=True` in Django settings.
## Enablement
### 1. Django setting
```python
# company_site/settings.py (or environment-specific settings)
WEBMCP_ENABLED = True
```
Default is `False` so production stays opt-in until you deliberately enable agent tooling.
### 2. Chrome experimental flag
WebMCP requires the experimental web platform features flag:
1. Open `chrome://flags/#enable-experimental-web-platform-features`
2. Set **Enable experimental web platform features** to **Enabled**
3. Restart Chrome
Serve the site over HTTPS (or `localhost`) — WebMCP requires a secure context.
### 3. Lighthouse audit
```bash
npx lighthouse@latest https://aimloperations.com/contact \
--only-categories=agentic-browsing \
--chrome-flags="--enable-experimental-web-platform-features" \
--output=html --output-path=agentic-browsing-contact.html
npx lighthouse@latest https://aimloperations.com/ \
--only-categories=agentic-browsing \
--chrome-flags="--enable-experimental-web-platform-features" \
--output=html --output-path=agentic-browsing-home.html
```
Use `https://` URLs to avoid redirect warnings. Run with `WEBMCP_ENABLED=True` on the target environment.
## Registered tools
| Tool | Pages | readOnly | Description |
|------|-------|----------|-------------|
| `list_services` | All public pages | Yes | Returns service name, slug, URL, and summary |
| `get_page_content` | All public pages | Yes | Look up a page by slug or display name |
| `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL |
| `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled |
| `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint |
### `submit_contact_inquiry`
**Input schema:**
| Field | Type | Required |
|-------|------|----------|
| `name` | string | Yes |
| `email` | string | Yes |
| `subject` | string | Yes |
| `message` | string | No |
**Output:** JSON with `success: true` and a confirmation message, or `success: false` with an `error` string.
**reCAPTCHA limitation:** In production (`DEBUG=False`), the contact endpoint requires reCAPTCHA v3. Automated agents cannot complete captcha without a real browser session and the reCAPTCHA widget. For Lighthouse demos, run with `DEBUG=True` or use the declarative HTML form (agent fills fields; user submits manually).
### Declarative form annotation
The contact `<form>` declares WebMCP coverage via HTML attributes (always rendered, independent of `WEBMCP_ENABLED`):
```html
<form id="contact-form" toolname="submit_contact_inquiry"
tooldescription="Submit a contact inquiry to AI ML Operations">
<input name="name" toolparamdescription="Full name of the person submitting the inquiry." required>
...
</form>
<!-- reCAPTCHA renders outside the annotated form and links via form="contact-form" -->
```
The imperative `registerTool` on `/contact` adds structured `execute` behavior with success/error responses when `WEBMCP_ENABLED=True`.
## Data source
Service metadata is sourced from `PUBLIC_PAGE_ENTRIES` in `public/seo.py` — the same config that powers `sitemap.xml` and `llms.txt`. Navigation tools always stay in sync with SEO endpoints.
## Security scope
- Tools run in the visitor's browser session (no API keys)
- Only unauthenticated marketing flows are exposed
- Planning, financial, and admin actions are **not** registered
## Manual verification
Use [Chrome Labs Awesome WebMCP](https://github.com/chrome-labs/awesome-webmcp) or DevTools console:
```javascript
// Feature-detect (Chrome with experimental flag)
'modelContext' in navigator && typeof navigator.modelContext.registerTool === 'function'
```
With a WebMCP-capable browser on `/`, call `list_services` from the agent UI. On `/contact`, call `submit_contact_inquiry` with test data (DEBUG mode).
## References
- [WebMCP specification overview](https://specification.website/spec/agent-readiness/webmcp/)
- [Google Chrome modern-web-guidance — agentic JavaScript tools](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/webmcp/agentic-javascript-tools.md)
- [Awesome WebMCP demos](https://github.com/chrome-labs/awesome-webmcp)
- Gitea issue #9
+6 -2
View File
@@ -1,6 +1,5 @@
from django.contrib import admin from django.contrib import admin
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile
# Register your models here.
class ContractAdmin(admin.ModelAdmin): class ContractAdmin(admin.ModelAdmin):
pass pass
@@ -8,6 +7,10 @@ class ContractAdmin(admin.ModelAdmin):
class EmployeeAdmin(admin.ModelAdmin): class EmployeeAdmin(admin.ModelAdmin):
pass pass
class UserProfileAdmin(admin.ModelAdmin):
list_display = ("user", "user_type")
list_filter = ("user_type",)
class ChargeNumberAdmin(admin.ModelAdmin): class ChargeNumberAdmin(admin.ModelAdmin):
pass pass
@@ -19,6 +22,7 @@ class TimeCardCellAdmin(admin.ModelAdmin):
admin.site.register(Contract, ContractAdmin) admin.site.register(Contract, ContractAdmin)
admin.site.register(Employee, EmployeeAdmin) admin.site.register(Employee, EmployeeAdmin)
admin.site.register(UserProfile, UserProfileAdmin)
admin.site.register(ChargeNumber, ChargeNumberAdmin) admin.site.register(ChargeNumber, ChargeNumberAdmin)
admin.site.register(TimeCard, TimeCardAdmin) admin.site.register(TimeCard, TimeCardAdmin)
admin.site.register(TimeCardCell, TimeCardCellAdmin) admin.site.register(TimeCardCell, TimeCardCellAdmin)
+12 -1
View File
@@ -1,7 +1,8 @@
import datetime import datetime
from django import forms from django import forms
from django.contrib.auth.models import User
from django.forms import ModelForm from django.forms import ModelForm
from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel, UserProfile, set_user_type
class NewEmployeeForm(ModelForm): class NewEmployeeForm(ModelForm):
first_name = forms.CharField(max_length=30, required=False, label="First Name") first_name = forms.CharField(max_length=30, required=False, label="First Name")
@@ -37,6 +38,7 @@ class NewEmployeeForm(ModelForm):
employee.workAddress = address employee.workAddress = address
if commit: if commit:
employee.save() employee.save()
set_user_type(employee.user, UserProfile.UserType.EMPLOYEE)
return employee return employee
class EmployeeForm(ModelForm): class EmployeeForm(ModelForm):
@@ -44,6 +46,15 @@ class EmployeeForm(ModelForm):
model = Employee model = Employee
fields = ["user", "manager", "primaryAddress", "workAddress", "phoneNumber", "slary"] fields = ["user", "manager", "primaryAddress", "workAddress", "phoneNumber", "slary"]
class UserProfileForm(ModelForm):
class Meta:
model = UserProfile
fields = ["user_type"]
class AdminUserTypeForm(forms.Form):
user = forms.ModelChoiceField(queryset=User.objects.order_by("username"))
user_type = forms.ChoiceField(choices=UserProfile.UserType.choices)
class ContractForm(ModelForm): class ContractForm(ModelForm):
class Meta: class Meta:
model = Contract model = Contract
@@ -0,0 +1,79 @@
# Generated manually for issue #14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
def migrate_user_profiles(apps, schema_editor):
User = apps.get_model("auth", "User")
UserProfile = apps.get_model("financial", "UserProfile")
Employee = apps.get_model("financial", "Employee")
TimeCardCell = apps.get_model("financial", "TimeCardCell")
employee_user_ids = set(
TimeCardCell.objects.values_list("timeCard__employee__user_id", flat=True)
)
for user in User.objects.all():
if user.id in employee_user_ids:
UserProfile.objects.update_or_create(
user_id=user.id,
defaults={"user_type": "employee"},
)
else:
UserProfile.objects.update_or_create(
user_id=user.id,
defaults={"user_type": "client"},
)
Employee.objects.filter(user_id=user.id).delete()
def reverse_migrate_user_profiles(apps, schema_editor):
User = apps.get_model("auth", "User")
Employee = apps.get_model("financial", "Employee")
for user in User.objects.all():
Employee.objects.get_or_create(user_id=user.id)
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("financial", "0014_chargenumber_name"),
]
operations = [
migrations.CreateModel(
name="UserProfile",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"user_type",
models.CharField(
choices=[("employee", "Employee"), ("client", "Client")],
default="client",
max_length=10,
),
),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="profile",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.RunPython(migrate_user_profiles, reverse_migrate_user_profiles),
]
+47
View File
@@ -243,6 +243,29 @@ class AddressModel(models.Model):
state = models.CharField(max_length=2) state = models.CharField(max_length=2)
zip_code = models.CharField(max_length=5) zip_code = models.CharField(max_length=5)
class UserProfile(models.Model):
class UserType(models.TextChoices):
EMPLOYEE = "employee", "Employee"
CLIENT = "client", "Client"
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
user_type = models.CharField(
max_length=10,
choices=UserType.choices,
default=UserType.CLIENT,
)
def __str__(self):
return f"{self.user} ({self.get_user_type_display()})"
def is_employee(self):
return self.user_type == self.UserType.EMPLOYEE
def is_client(self):
return self.user_type == self.UserType.CLIENT
class Employee(IdMixin, TimeMixin): class Employee(IdMixin, TimeMixin):
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True) manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
user = models.OneToOneField(User, on_delete=models.CASCADE) user = models.OneToOneField(User, on_delete=models.CASCADE)
@@ -279,6 +302,30 @@ class TimeCardCell(IdMixin, TimeMixin):
charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True) charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True)
def set_user_type(user, user_type):
"""Set user type and sync the Employee record (mutually exclusive types)."""
user.__dict__.pop("profile", None)
profile, _ = UserProfile.objects.get_or_create(
user=user,
defaults={"user_type": user_type},
)
if profile.user_type == user_type:
if user_type == UserProfile.UserType.EMPLOYEE:
Employee.objects.get_or_create(user=user)
return profile
if user_type == UserProfile.UserType.CLIENT:
employee = Employee.objects.filter(user=user).first()
if employee and TimeCardCell.objects.filter(timeCard__employee=employee).exists():
raise ValueError("Cannot set Client: user has time log entries.")
profile.user_type = user_type
profile.save()
user.__dict__.pop("profile", None)
if user_type == UserProfile.UserType.EMPLOYEE:
Employee.objects.get_or_create(user=user)
else:
Employee.objects.filter(user=user).delete()
return profile
+76
View File
@@ -0,0 +1,76 @@
from functools import wraps
from django.contrib.auth.decorators import login_required, user_passes_test
from django.core.exceptions import PermissionDenied
def get_user_profile(user):
if not user.is_authenticated:
return None
from .models import UserProfile
return UserProfile.objects.filter(user_id=user.pk).first()
def is_financial_admin(user):
return user.is_active and user.is_superuser
def is_employee_user(user):
profile = get_user_profile(user)
return bool(profile and profile.is_employee())
def is_client_user(user):
profile = get_user_profile(user)
return bool(profile and profile.is_client())
def has_financial_access(user):
return is_financial_admin(user) or is_employee_user(user) or is_client_user(user)
def can_write_financials(user):
return is_financial_admin(user) or is_employee_user(user)
def get_employees():
from .models import Employee, UserProfile
return Employee.objects.filter(
user__profile__user_type=UserProfile.UserType.EMPLOYEE
)
def get_user_employee(user):
from .models import Employee
if not is_employee_user(user):
return None
return Employee.objects.filter(user=user).first()
def financial_admin_required(view_func):
return user_passes_test(is_financial_admin)(view_func)
def financial_access_required(view_func):
@login_required
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
if has_financial_access(request.user):
return view_func(request, *args, **kwargs)
raise PermissionDenied
return _wrapped
def financial_write_required(view_func):
@login_required
@wraps(view_func)
def _wrapped(request, *args, **kwargs):
if can_write_financials(request.user):
return view_func(request, *args, **kwargs)
raise PermissionDenied
return _wrapped
+8 -4
View File
@@ -2,10 +2,14 @@ from django.db.models.signals import post_save
from django.dispatch import receiver from django.dispatch import receiver
from django.contrib.auth.models import User from django.contrib.auth.models import User
from .models import UserProfile
@receiver(post_save, sender=User) @receiver(post_save, sender=User)
def create_employee_for_user(sender, instance, created, **kwargs): def create_profile_for_user(sender, instance, created, **kwargs):
"""Auto-create an Employee record whenever a User is created.""" """Auto-create a UserProfile (default Client) whenever a User is created."""
from financial.models import Employee
if created: if created:
Employee.objects.get_or_create(user=instance) UserProfile.objects.get_or_create(
user=instance,
defaults={"user_type": UserProfile.UserType.CLIENT},
)
@@ -9,6 +9,7 @@
<h1 class="section-title">Dashboard</h1> <h1 class="section-title">Dashboard</h1>
<div class="card-grid" style="margin-bottom: 3rem;"> <div class="card-grid" style="margin-bottom: 3rem;">
{% if is_financial_admin %}
<a href="{% url 'contracts' %}" class="card" <a href="{% url 'contracts' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="contracts"> data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
<span class="card-title">View Contracts</span> <span class="card-title">View Contracts</span>
@@ -24,15 +25,22 @@
<span class="card-title">New Employee</span> <span class="card-title">New Employee</span>
<p class="card-text">Add a new personnel member to your organization.</p> <p class="card-text">Add a new personnel member to your organization.</p>
</a> </a>
<a href="{% url 'manage_users' %}" class="card">
<span class="card-title">Manage Users</span>
<p class="card-text">Set Employee or Client type for user accounts.</p>
</a>
{% endif %}
{% if can_write_financials %}
<a href="{% url 'Timekeeping' %}" class="card" <a href="{% url 'Timekeeping' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping"> data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping">
<span class="card-title">Log Time</span> <span class="card-title">Log Time</span>
<p class="card-text">Record work hours against specific contracts.</p> <p class="card-text">Record work hours against specific contracts.</p>
</a> </a>
{% endif %}
<a href="{% url 'time_logs' %}" class="card" <a href="{% url 'time_logs' %}" class="card"
data-tianji-event="financial_nav" data-tianji-event-destination="time_logs"> data-tianji-event="financial_nav" data-tianji-event-destination="time_logs">
<span class="card-title">Manage Time Logs</span> <span class="card-title">{% if can_write_financials %}Manage{% else %}View{% endif %} Time Logs</span>
<p class="card-text">Review and edit submitted time entries.</p> <p class="card-text">Review{% if can_write_financials %} and edit{% endif %} submitted time entries.</p>
</a> </a>
<a href="{% url 'client_reports' %}" class="card"> <a href="{% url 'client_reports' %}" class="card">
<span class="card-title">Client Reports</span> <span class="card-title">Client Reports</span>
@@ -0,0 +1,50 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Manage Users - AI ML Operations{% endblock %}
{% block content %}
<div class="section">
<div class="container">
<h1 class="section-title" style="text-align: left;">Manage Users</h1>
{% if messages %}
{% for message in messages %}
<p style="margin-bottom: 1rem; color: {% if message.tags == 'error' %}#ff6666{% else %}var(--primary-color){% endif %};">
{{ message }}
</p>
{% endfor %}
{% endif %}
<div class="card" style="max-width: 600px; margin-bottom: 2rem;">
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Set User Type</h2>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<button type="submit" class="btn" style="margin-top: 1rem;">Update User Type</button>
</form>
</div>
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Name</th>
<th>Type</th>
</tr>
</thead>
<tbody>
{% for u in users %}
<tr>
<td>{{ u.username }}</td>
<td>{{ u.get_full_name|default:"—" }}</td>
<td>{{ u.profile.get_user_type_display|default:"Client" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
@@ -7,13 +7,36 @@
<div class="section"> <div class="section">
<div class="container"> <div class="container">
<h1 class="section-title" style="text-align: left;">Profile</h1> <h1 class="section-title" style="text-align: left;">Profile</h1>
<div class="card" style="max-width: 600px;">
{% if messages %}
{% for message in messages %}
<p style="margin-bottom: 1rem; color: {% if message.tags == 'error' %}#ff6666{% else %}var(--primary-color){% endif %};">
{{ message }}
</p>
{% endfor %}
{% endif %}
<div class="card" style="max-width: 600px; margin-bottom: 2rem;">
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Account Type</h2>
<p style="color: var(--text-muted); margin-bottom: 1rem;">
Your account is currently: <strong>{{ profile.get_user_type_display }}</strong>
</p>
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_p }}
{% if can_edit_type %}
<button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button> <button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button>
{% endif %}
</form> </form>
</div> </div>
{% if employee_form %}
<div class="card" style="max-width: 600px;">
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Employee Details</h2>
{{ employee_form.as_p }}
<p style="color: var(--text-muted); font-size: 0.9rem;">Contact an admin to update employee details.</p>
</div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@@ -9,11 +9,13 @@
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h1 class="section-title" style="margin-bottom: 0;">All Time Logs</h1> <h1 class="section-title" style="margin-bottom: 0;">All Time Logs</h1>
<div> <div>
<a href="{% url 'financial_index' %}" class="btn" <a href="{% url 'financial_home' %}" class="btn"
style="padding: 0.5rem 1.5rem; font-size: 0.9rem; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1);">Back style="padding: 0.5rem 1.5rem; font-size: 0.9rem; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1);">Back
to Dashboard</a> to Dashboard</a>
{% if can_edit_logs %}
<a href="{% url 'Timekeeping' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Log New <a href="{% url 'Timekeeping' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Log New
Time</a> Time</a>
{% endif %}
</div> </div>
</div> </div>
@@ -144,6 +146,7 @@
<td>{{ log.end_time|default_if_none:"" }}</td> <td>{{ log.end_time|default_if_none:"" }}</td>
<td>{{ log.hour }}</td> <td>{{ log.hour }}</td>
<td> <td>
{% if can_edit_logs %}
<a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan" <a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan"
style="margin-right: 10px;">Edit</a> style="margin-right: 10px;">Edit</a>
<form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;" <form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;"
@@ -152,6 +155,9 @@
<button type="submit" <button type="submit"
style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button> style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button>
</form> </form>
{% else %}
<span style="color: var(--text-muted);">Read only</span>
{% endif %}
</td> </td>
</tr> </tr>
{% empty %} {% empty %}
+158 -2
View File
@@ -1,3 +1,159 @@
from django.test import TestCase from django.contrib.auth.models import User
from django.test import Client, TestCase
from django.urls import reverse
# Create your tests here. from financial.models import (
AddressModel,
ChargeNumber,
Contract,
Employee,
TimeCard,
TimeCardCell,
UserProfile,
set_user_type,
)
from financial.permissions import get_employees, is_client_user, is_employee_user
class UserProfileSignalTests(TestCase):
def test_new_user_gets_client_profile_not_employee(self):
user = User.objects.create_user(username="newbie", password="pass")
self.assertTrue(UserProfile.objects.filter(user=user, user_type=UserProfile.UserType.CLIENT).exists())
self.assertFalse(Employee.objects.filter(user=user).exists())
class SetUserTypeTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="worker", password="pass")
UserProfile.objects.filter(user=self.user).delete()
def test_set_employee_creates_employee_record(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
self.assertTrue(Employee.objects.filter(user=self.user).exists())
self.assertEqual(self.user.profile.user_type, UserProfile.UserType.EMPLOYEE)
def test_set_client_removes_employee_without_time_entries(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
set_user_type(self.user, UserProfile.UserType.CLIENT)
self.assertFalse(Employee.objects.filter(user=self.user).exists())
self.assertEqual(
UserProfile.objects.get(user=self.user).user_type,
UserProfile.UserType.CLIENT,
)
def test_cannot_set_client_with_time_entries(self):
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
employee = Employee.objects.get(user=self.user)
contract = Contract.objects.create(
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
name="Test Contract",
)
charge = ChargeNumber.objects.create(
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
contract=contract,
)
time_card = TimeCard.objects.create(employee=employee)
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=2.0)
with self.assertRaises(ValueError):
set_user_type(self.user, UserProfile.UserType.CLIENT)
class EmployeeFilterTests(TestCase):
def setUp(self):
self.employee_user = User.objects.create_user(username="emp", password="pass")
self.client_user = User.objects.create_user(username="cli", password="pass")
set_user_type(self.employee_user, UserProfile.UserType.EMPLOYEE)
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
def test_get_employees_excludes_clients(self):
employees = list(get_employees())
self.assertEqual(len(employees), 1)
self.assertEqual(employees[0].user, self.employee_user)
def test_permission_helpers(self):
self.assertTrue(is_employee_user(self.employee_user))
self.assertFalse(is_employee_user(self.client_user))
self.assertTrue(is_client_user(self.client_user))
self.assertFalse(is_client_user(self.employee_user))
class FinancialAccessTests(TestCase):
def setUp(self):
self.client = Client()
self.admin = User.objects.create_superuser(username="admin", password="pass", email="a@test.com")
self.employee = User.objects.create_user(username="employee", password="pass")
self.client_user = User.objects.create_user(username="client", password="pass")
set_user_type(self.employee, UserProfile.UserType.EMPLOYEE)
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
contract = Contract.objects.create(
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
name="C1",
budget_hours=100,
)
charge = ChargeNumber.objects.create(
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
contract=contract,
)
emp_record = Employee.objects.get(user=self.employee)
time_card = TimeCard.objects.create(employee=emp_record)
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=4.0)
def test_client_can_view_reports_readonly(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("client_reports"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "C1")
def test_client_cannot_log_time(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("Timekeeping"))
self.assertEqual(response.status_code, 403)
def test_client_can_view_time_logs_without_edit(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("time_logs"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Read only")
self.assertNotContains(response, 'href="/financial/time_logs/')
def test_employee_can_access_timekeeping(self):
self.client.login(username="employee", password="pass")
response = self.client.get(reverse("Timekeeping"))
self.assertEqual(response.status_code, 200)
def test_client_redirected_from_financial_home_to_reports(self):
self.client.login(username="client", password="pass")
response = self.client.get(reverse("financial_home"))
self.assertRedirects(response, reverse("client_reports"))
def test_admin_can_manage_users(self):
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("manage_users"))
self.assertEqual(response.status_code, 200)
response = self.client.post(reverse("manage_users"), {
"user": self.client_user.id,
"user_type": UserProfile.UserType.EMPLOYEE,
})
self.assertRedirects(response, reverse("manage_users"))
self.client_user.refresh_from_db()
self.assertEqual(self.client_user.profile.user_type, UserProfile.UserType.EMPLOYEE)
def test_time_logs_only_lists_employees(self):
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("time_logs"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "employee")
employees = response.context["employees"]
self.assertEqual(employees.count(), 1)
def test_new_user_not_in_employee_dropdown(self):
extra = User.objects.create_user(username="extra", password="pass")
set_user_type(extra, UserProfile.UserType.CLIENT)
self.client.login(username="admin", password="pass")
response = self.client.get(reverse("time_logs"))
employees = response.context["employees"]
usernames = [e.user.username for e in employees]
self.assertIn("employee", usernames)
self.assertNotIn("extra", usernames)
+3 -1
View File
@@ -3,7 +3,8 @@ from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path("", views.index, name="financial_index"), path("", views.financial_home, name="financial_home"),
path("dashboard", views.index, name="financial_index"),
path("timekeeping", views.timekeeping, name="Timekeeping"), path("timekeeping", views.timekeeping, name="Timekeeping"),
path("time_logs", views.time_logs, name="time_logs"), path("time_logs", views.time_logs, name="time_logs"),
path("time_logs/<int:log_id>/edit", views.edit_time_log, name="edit_time_log"), path("time_logs/<int:log_id>/edit", views.edit_time_log, name="edit_time_log"),
@@ -18,5 +19,6 @@ urlpatterns = [
#path("contracts/<int:contract_id>/", views.contract_detail, name="contract"), #path("contracts/<int:contract_id>/", views.contract_detail, name="contract"),
path("procurements", views.procurement, name="procurements"), path("procurements", views.procurement, name="procurements"),
path("profile", views.profile, name="profile"), path("profile", views.profile, name="profile"),
path("manage_users", views.manage_users, name="manage_users"),
path("client_reports", views.client_reports, name="client_reports"), path("client_reports", views.client_reports, name="client_reports"),
] ]
+142 -32
View File
@@ -1,23 +1,41 @@
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.contrib.auth.decorators import user_passes_test from django.contrib.auth.models import User
from .forms import EmployeeForm, ContractForm, ChargeNumberForm, TimeLogForm, NewEmployeeForm from django.contrib import messages
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee
from django.utils import timezone from django.utils import timezone
from django.db.models import Sum from django.db.models import Sum
from datetime import timedelta from datetime import timedelta
import json import json
def is_admin(user): from .forms import (
return user.is_active and user.is_superuser EmployeeForm,
ContractForm,
ChargeNumberForm,
TimeLogForm,
NewEmployeeForm,
UserProfileForm,
AdminUserTypeForm,
)
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee, UserProfile
from .permissions import (
financial_admin_required,
financial_access_required,
financial_write_required,
get_employees,
get_user_employee,
is_client_user,
is_financial_admin,
can_write_financials,
)
@user_passes_test(is_admin)
@financial_admin_required
def index(request): def index(request):
contracts = Contract.objects.all() contracts = Contract.objects.all()
for c in contracts: for c in contracts:
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum'] total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
c.total_logged = total if total else 0.0 c.total_logged = total if total else 0.0
employees = Employee.objects.all() employees = get_employees()
employee_data = [] employee_data = []
for e in employees: for e in employees:
contract_hours = [] contract_hours = []
@@ -28,10 +46,20 @@ def index(request):
return render(request, "financial/index.html", { return render(request, "financial/index.html", {
'contracts': contracts, 'contracts': contracts,
'employee_data': employee_data 'employee_data': employee_data,
}) })
@user_passes_test(is_admin)
@financial_access_required
def financial_home(request):
if is_financial_admin(request.user):
return redirect('financial_index')
if is_client_user(request.user):
return redirect('client_reports')
return redirect('Timekeeping')
@financial_admin_required
def new_employee(request): def new_employee(request):
if request.method == "POST": if request.method == "POST":
form = NewEmployeeForm(request.POST) form = NewEmployeeForm(request.POST)
@@ -42,7 +70,8 @@ def new_employee(request):
form = NewEmployeeForm() form = NewEmployeeForm()
return render(request, 'financial/new_employee.html', {"form": form}) return render(request, 'financial/new_employee.html', {"form": form})
@user_passes_test(is_admin)
@financial_admin_required
def contracts(request): def contracts(request):
contracts_list = Contract.objects.all() contracts_list = Contract.objects.all()
today = timezone.now().date() today = timezone.now().date()
@@ -102,7 +131,8 @@ def contracts(request):
'chart_data_json': json.dumps(chart_data_list) 'chart_data_json': json.dumps(chart_data_list)
}) })
@user_passes_test(is_admin)
@financial_admin_required
def contract_detail(request, contract_slug): def contract_detail(request, contract_slug):
contract = Contract.objects.filter(slug=contract_slug).first() contract = Contract.objects.filter(slug=contract_slug).first()
@@ -122,7 +152,6 @@ def contract_detail(request, contract_slug):
lines_str = "\n".join(mermaid_gantt_lines) lines_str = "\n".join(mermaid_gantt_lines)
mermaid_gantt = f"gantt\n title {contract.name} Charge Numbers Timeline\n dateFormat YYYY-MM-DD\n section Charge Numbers\n{lines_str}" mermaid_gantt = f"gantt\n title {contract.name} Charge Numbers Timeline\n dateFormat YYYY-MM-DD\n section Charge Numbers\n{lines_str}"
# --- EVM Data ---
evm = contract.get_evm_data() if contract else {} evm = contract.get_evm_data() if contract else {}
evm_chart_json = json.dumps({ evm_chart_json = json.dumps({
'time_series': evm.get('time_series', []), 'time_series': evm.get('time_series', []),
@@ -149,7 +178,8 @@ def contract_detail(request, contract_slug):
'evm_chart_json': evm_chart_json, 'evm_chart_json': evm_chart_json,
}) })
@user_passes_test(is_admin)
@financial_admin_required
def new_contract(request): def new_contract(request):
if request.method == "POST": if request.method == "POST":
form = ContractForm(request.POST) form = ContractForm(request.POST)
@@ -160,27 +190,42 @@ def new_contract(request):
form = ContractForm() form = ContractForm()
return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True}) return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True})
@user_passes_test(is_admin)
@financial_write_required
def timekeeping(request): def timekeeping(request):
employee = get_user_employee(request.user)
if not employee and not is_financial_admin(request.user):
messages.error(request, "Only employees can log time.")
return redirect('financial_home')
if request.method == "POST": if request.method == "POST":
form = TimeLogForm(request.POST) form = TimeLogForm(request.POST)
if form.is_valid(): if form.is_valid():
employee, _ = Employee.objects.get_or_create(user=request.user) if is_financial_admin(request.user) and not employee:
messages.error(request, "Admin must have an Employee profile to log time here.")
return redirect('time_logs')
time_card, _ = TimeCard.objects.get_or_create(employee=employee, startDate=timezone.now().date(), endDate=timezone.now().date()) time_card, _ = TimeCard.objects.get_or_create(
employee=employee,
startDate=timezone.now().date(),
endDate=timezone.now().date(),
)
cell = form.save(commit=False) cell = form.save(commit=False)
cell.timeCard = time_card cell.timeCard = time_card
cell.save() cell.save()
return redirect('financial_index') return redirect('financial_home')
else: else:
form = TimeLogForm() form = TimeLogForm()
return render(request, 'financial/timekeeping.html', {'form': form}) return render(request, 'financial/timekeeping.html', {'form': form})
@user_passes_test(is_admin)
@financial_access_required
def time_logs(request): def time_logs(request):
logs = TimeCardCell.objects.select_related( logs = TimeCardCell.objects.select_related(
'timeCard__employee__user', 'timeCard__employee__user',
'charge_number__contract', 'charge_number__contract',
).filter(
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
).order_by('-date', '-created') ).order_by('-date', '-created')
employee_ids = [] employee_ids = []
@@ -231,12 +276,13 @@ def time_logs(request):
return render(request, 'financial/time_logs.html', { return render(request, 'financial/time_logs.html', {
'logs': logs, 'logs': logs,
'employees': Employee.objects.select_related('user').order_by('user__last_name', 'user__first_name'), 'employees': get_employees().select_related('user').order_by('user__last_name', 'user__first_name'),
'contracts': Contract.objects.order_by('name'), 'contracts': Contract.objects.order_by('name'),
'charge_numbers': charge_numbers, 'charge_numbers': charge_numbers,
'contract_totals': contract_totals, 'contract_totals': contract_totals,
'charge_number_totals': charge_number_totals, 'charge_number_totals': charge_number_totals,
'grand_total': grand_total, 'grand_total': grand_total,
'can_edit_logs': can_write_financials(request.user),
'filters': { 'filters': {
'employees': [str(eid) for eid in employee_ids], 'employees': [str(eid) for eid in employee_ids],
'month': month or '', 'month': month or '',
@@ -245,7 +291,8 @@ def time_logs(request):
}, },
}) })
@user_passes_test(is_admin)
@financial_write_required
def edit_time_log(request, log_id): def edit_time_log(request, log_id):
log_entry = TimeCardCell.objects.filter(id=log_id).first() log_entry = TimeCardCell.objects.filter(id=log_id).first()
if not log_entry: if not log_entry:
@@ -261,7 +308,8 @@ def edit_time_log(request, log_id):
return render(request, 'financial/edit_time_log.html', {'form': form, 'log': log_entry}) return render(request, 'financial/edit_time_log.html', {'form': form, 'log': log_entry})
@user_passes_test(is_admin)
@financial_write_required
def delete_time_log(request, log_id): def delete_time_log(request, log_id):
if request.method == "POST": if request.method == "POST":
log_entry = TimeCardCell.objects.filter(id=log_id).first() log_entry = TimeCardCell.objects.filter(id=log_id).first()
@@ -269,16 +317,24 @@ def delete_time_log(request, log_id):
log_entry.delete() log_entry.delete()
return redirect('time_logs') return redirect('time_logs')
@user_passes_test(is_admin)
@financial_access_required
def client_reports(request): def client_reports(request):
contracts = Contract.objects.all() contracts = Contract.objects.all()
for c in contracts: for c in contracts:
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum'] total = TimeCardCell.objects.filter(
charge_number__contract=c,
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
).aggregate(Sum('hour'))['hour__sum']
c.total_logged = total if total else 0.0 c.total_logged = total if total else 0.0
c.remaining_budget = c.budget_hours - c.total_logged c.remaining_budget = c.budget_hours - c.total_logged
return render(request, 'financial/reports.html', {'contracts': contracts}) return render(request, 'financial/reports.html', {
'contracts': contracts,
'read_only': is_client_user(request.user),
})
@user_passes_test(is_admin)
@financial_admin_required
def update_charge_number(request, charge_number_slug): def update_charge_number(request, charge_number_slug):
charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first() charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first()
if not charge_number: if not charge_number:
@@ -297,7 +353,8 @@ def update_charge_number(request, charge_number_slug):
'charge_number': charge_number, 'charge_number': charge_number,
}) })
@user_passes_test(is_admin)
@financial_admin_required
def new_charge_number(request, contract_slug): def new_charge_number(request, contract_slug):
contract = Contract.objects.filter(slug=contract_slug).first() contract = Contract.objects.filter(slug=contract_slug).first()
if request.method == "POST": if request.method == "POST":
@@ -309,19 +366,72 @@ def new_charge_number(request, contract_slug):
return redirect('contract_detail', contract_slug=contract.slug) return redirect('contract_detail', contract_slug=contract.slug)
return redirect('contract_detail', contract_slug=contract_slug) return redirect('contract_detail', contract_slug=contract_slug)
@user_passes_test(is_admin)
@financial_admin_required
def timeapproval(request): def timeapproval(request):
return render(request, 'financial/not_created.html', {}) return render(request, 'financial/not_created.html', {})
@user_passes_test(is_admin)
@financial_admin_required
def chargenumber(request): def chargenumber(request):
return render(request, 'financial/not_created.html', {}) return render(request, 'financial/not_created.html', {})
@user_passes_test(is_admin)
@financial_admin_required
def procurement(request): def procurement(request):
return render(request, 'financial/procurement.html', {}) return render(request, 'financial/procurement.html', {})
@user_passes_test(is_admin)
@financial_access_required
def profile(request): def profile(request):
form = EmployeeForm() profile_obj, _ = UserProfile.objects.get_or_create(user=request.user)
return render(request, 'financial/profile.html', {'form': form}) can_edit_type = is_financial_admin(request.user)
if request.method == "POST" and can_edit_type:
form = UserProfileForm(request.POST, instance=profile_obj)
if form.is_valid():
from .models import set_user_type
try:
set_user_type(request.user, form.cleaned_data['user_type'])
messages.success(request, "Profile updated.")
except ValueError as exc:
messages.error(request, str(exc))
return redirect('profile')
else:
form = UserProfileForm(instance=profile_obj)
if not can_edit_type:
form.fields['user_type'].disabled = True
employee = Employee.objects.filter(user=request.user).first()
employee_form = None
if employee and profile_obj.is_employee():
employee_form = EmployeeForm(instance=employee)
return render(request, 'financial/profile.html', {
'form': form,
'employee_form': employee_form,
'profile': profile_obj,
'can_edit_type': can_edit_type,
})
@financial_admin_required
def manage_users(request):
if request.method == "POST":
form = AdminUserTypeForm(request.POST)
if form.is_valid():
from .models import set_user_type
try:
set_user_type(form.cleaned_data['user'], form.cleaned_data['user_type'])
messages.success(request, "User type updated.")
return redirect('manage_users')
except ValueError as exc:
messages.error(request, str(exc))
else:
form = AdminUserTypeForm()
users = User.objects.select_related('profile').order_by('username')
return render(request, 'financial/manage_users.html', {
'form': form,
'users': users,
})
+62
View File
@@ -1,4 +1,9 @@
import json
from django.conf import settings from django.conf import settings
from django.urls import reverse
from .seo import PUBLIC_PAGE_ENTRIES, get_service_entries
def tianji_tracking(request): def tianji_tracking(request):
@@ -16,3 +21,60 @@ def tianji_tracking(request):
), ),
'page_name': getattr(getattr(request, 'resolver_match', None), 'url_name', ''), '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),
}
+3 -2
View File
@@ -7,8 +7,9 @@ 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,
private_key=settings.RECAPTCHA_PRIVATE_KEY, private_key=settings.RECAPTCHA_PRIVATE_KEY,
+164
View File
@@ -0,0 +1,164 @@
"""Machine-readable site discovery endpoints for crawlers and AI agents."""
from django.http import HttpResponse
from django.template.loader import render_to_string
from django.urls import reverse
# Public marketing pages included in sitemap and llms.txt.
# Tuple: (url_name, title, changefreq, priority, summary)
PUBLIC_PAGE_ENTRIES = (
(
"public_index",
"Home",
"weekly",
"1.0",
"Company homepage with an overview of AI ML Operations services.",
),
(
"forward_deployed",
"Forward-Deployed AI",
"monthly",
"0.9",
"Embedded AI engineering — we work inside your environment to build production systems.",
),
(
"bot",
"AI Agents",
"monthly",
"0.9",
"Custom AI agents and agentic workflows tailored to your operational bottlenecks.",
),
(
"ml_model",
"ML Models",
"monthly",
"0.8",
"Machine learning model development, training, and deployment for production use.",
),
(
"chat",
"Secure AI Chat",
"monthly",
"0.8",
"Private, hosted AI chat deployments with enterprise-grade security.",
),
(
"ai_sensor",
"AI Sensor Algorithms",
"monthly",
"0.7",
"Computer vision and sensor-fusion algorithms for real-world sensing applications.",
),
(
"ai_education",
"AI Education",
"monthly",
"0.7",
"Hands-on AI training and workshops for teams adopting agentic workflows.",
),
(
"computers",
"Computer Builds",
"monthly",
"0.7",
"Custom workstation and server builds optimized for AI and ML workloads.",
),
(
"file_hosting",
"File Hosting",
"monthly",
"0.7",
"Managed file hosting and storage for teams that need reliable data access.",
),
(
"web_design",
"Web Design and Hosting",
"monthly",
"0.8",
"Web design, development, and managed hosting for business sites and apps.",
),
(
"contact",
"Contact",
"monthly",
"0.9",
"Contact form to inquire about forward-deployed AI engineering services.",
),
(
"terms_of_service",
"Terms of Service and Privacy",
"yearly",
"0.3",
"Terms of service and privacy policy for AI ML Operations, LLC.",
),
)
SERVICE_URL_NAMES = frozenset({
"forward_deployed",
"bot",
"ml_model",
"chat",
"ai_sensor",
"ai_education",
"computers",
"file_hosting",
"web_design",
})
def get_service_entries():
"""Return service page metadata for WebMCP navigation tools."""
return [
{
"slug": url_name,
"name": title,
"summary": summary,
}
for url_name, title, _changefreq, _priority, summary in PUBLIC_PAGE_ENTRIES
if url_name in SERVICE_URL_NAMES
]
def _absolute_url(request, url_name):
return request.build_absolute_uri(reverse(url_name))
def robots_txt(request):
sitemap_url = _absolute_url(request, "sitemap_xml")
content = render_to_string(
"public/robots.txt",
{"sitemap_url": sitemap_url},
)
return HttpResponse(content, content_type="text/plain; charset=utf-8")
def sitemap_xml(request):
pages = [
{
"loc": _absolute_url(request, url_name),
"changefreq": changefreq,
"priority": priority,
}
for url_name, _title, changefreq, priority, _summary in PUBLIC_PAGE_ENTRIES
]
content = render_to_string("public/sitemap.xml", {"pages": pages})
return HttpResponse(content, content_type="application/xml; charset=utf-8")
def llms_txt(request):
pages = [
{
"title": title,
"url": _absolute_url(request, url_name),
}
for url_name, title, _changefreq, _priority, _summary in PUBLIC_PAGE_ENTRIES
]
content = render_to_string(
"public/llms.txt",
{
"site_url": request.build_absolute_uri("/"),
"contact_url": _absolute_url(request, "contact"),
"pages": pages,
},
)
return HttpResponse(content, content_type="text/plain; charset=utf-8")
+153 -2
View File
@@ -23,6 +23,7 @@ body {
font-family: var(--font-main); font-family: var(--font-main);
line-height: 1.6; line-height: 1.6;
overflow-x: hidden; overflow-x: hidden;
text-rendering: optimizeLegibility;
} }
a { a {
@@ -81,6 +82,7 @@ nav {
.hero-section { .hero-section {
position: relative; position: relative;
height: 100vh; height: 100vh;
min-height: 520px;
width: 100%; width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -88,6 +90,11 @@ nav {
overflow: hidden; overflow: hidden;
} }
.hero-section--compact {
height: 40vh;
min-height: 300px;
}
#hero-canvas { #hero-canvas {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -95,6 +102,7 @@ nav {
width: 100%; width: 100%;
height: 100%; height: 100%;
z-index: 0; z-index: 0;
pointer-events: none;
} }
.hero-content { .hero-content {
@@ -102,11 +110,14 @@ nav {
z-index: 1; z-index: 1;
text-align: center; text-align: center;
padding: 0 1rem; padding: 0 1rem;
min-height: 12rem;
} }
.hero-title { .hero-title {
font-size: 4rem; font-size: clamp(2.5rem, 8vw, 4rem);
font-weight: 800; font-weight: 800;
line-height: 1.1;
min-height: 1.1em;
background: linear-gradient(45deg, var(--primary-color), var(--secondary-color)); background: linear-gradient(45deg, var(--primary-color), var(--secondary-color));
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
@@ -282,6 +293,41 @@ nav {
margin-bottom: 1.5rem; 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 { .form-control {
width: 100%; width: 100%;
padding: 1rem; padding: 1rem;
@@ -376,7 +422,29 @@ nav {
display: block; display: block;
} }
.dropdown>a::after { .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: ' ▼'; content: ' ▼';
font-size: 0.7em; font-size: 0.7em;
margin-left: 5px; margin-left: 5px;
@@ -387,12 +455,72 @@ nav {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0.25rem; padding: 0.25rem;
background: none;
border: none;
cursor: pointer;
font: inherit;
} }
.profile-icon-link::after { .profile-icon-link::after {
display: none !important; 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 { .profile-icon {
width: 28px; width: 28px;
height: 28px; height: 28px;
@@ -599,6 +727,21 @@ input:focus, select:focus, textarea:focus {
color: var(--primary-color); 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 { .footer-separator {
color: var(--text-muted); color: var(--text-muted);
margin: 0 0.5rem; margin: 0 0.5rem;
@@ -637,6 +780,14 @@ input:focus, select:focus, textarea:focus {
margin: 0 0 1rem 1.5rem; 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 */
.cookie-consent-banner { .cookie-consent-banner {
position: fixed; position: fixed;
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

@@ -1,4 +1,7 @@
const canvas = document.getElementById('hero-canvas'); const canvas = document.getElementById('hero-canvas');
if (!canvas) {
// Hero animation only runs on the homepage.
} else {
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
let width, height; let width, height;
@@ -109,3 +112,4 @@ function animate() {
} }
animate(); animate();
}
@@ -1,7 +1,9 @@
(function () { (function () {
'use strict'; 'use strict';
var CONSENT_KEY = 'aiml_analytics_consent'; var NOTICE_KEY = 'aiml_analytics_notice';
var LEGACY_CONSENT_KEY = 'aiml_analytics_consent';
var LEGACY_DISABLED_KEY = 'tianji.disabled';
var configEl = document.getElementById('tianji-config'); var configEl = document.getElementById('tianji-config');
if (!configEl) { if (!configEl) {
return; return;
@@ -10,19 +12,26 @@
var trackerUrl = configEl.dataset.trackerUrl; var trackerUrl = configEl.dataset.trackerUrl;
var websiteId = configEl.dataset.websiteId; var websiteId = configEl.dataset.websiteId;
var userId = configEl.dataset.userId || ''; var userId = configEl.dataset.userId || '';
var pendingConsentEvent = false;
function getConsent() { function getStorageItem(key) {
try { try {
return localStorage.getItem(CONSENT_KEY); return localStorage.getItem(key);
} catch (e) { } catch (e) {
return null; return null;
} }
} }
function setConsent(value) { function setStorageItem(key, value) {
try { try {
localStorage.setItem(CONSENT_KEY, value); localStorage.setItem(key, value);
} catch (e) {
/* ignore storage errors */
}
}
function removeStorageItem(key) {
try {
localStorage.removeItem(key);
} catch (e) { } catch (e) {
/* ignore storage errors */ /* ignore storage errors */
} }
@@ -71,51 +80,44 @@
script.onload = function () { script.onload = function () {
identifyUser(); identifyUser();
if (pendingConsentEvent) {
trackEvent('consent_accepted');
pendingConsentEvent = false;
}
document.dispatchEvent(new CustomEvent('tianji:ready')); document.dispatchEvent(new CustomEvent('tianji:ready'));
}; };
document.head.appendChild(script); document.head.appendChild(script);
} }
function acceptTracking() { function migrateLegacyConsent() {
setConsent('accepted'); var legacyConsent = getStorageItem(LEGACY_CONSENT_KEY);
hideBanner(); var legacyDisabled = getStorageItem(LEGACY_DISABLED_KEY);
try {
localStorage.removeItem('tianji.disabled'); if (legacyConsent === 'accepted') {
} catch (e) { setStorageItem(NOTICE_KEY, 'acknowledged');
/* ignore storage errors */ removeStorageItem(LEGACY_CONSENT_KEY);
removeStorageItem(LEGACY_DISABLED_KEY);
return 'migrated_acknowledged';
} }
pendingConsentEvent = true;
loadTracker(); if (legacyConsent === 'declined' || legacyDisabled === '1') {
removeStorageItem(LEGACY_CONSENT_KEY);
removeStorageItem(LEGACY_DISABLED_KEY);
return 'migrated_declined';
}
return null;
} }
function declineTracking() { function acknowledgeNotice() {
setConsent('declined'); setStorageItem(NOTICE_KEY, 'acknowledged');
hideBanner(); hideBanner();
try { window.aimlTrackWhenReady('notice_acknowledged');
localStorage.setItem('tianji.disabled', '1');
} catch (e) {
/* ignore storage errors */
}
if (window.aimlTrack) {
window.aimlTrack('consent_declined');
}
} }
function bindBannerControls() { function bindBannerControls() {
var acceptBtn = document.getElementById('cookie-consent-accept'); var acknowledgeBtn = document.getElementById('cookie-consent-acknowledge');
var declineBtn = document.getElementById('cookie-consent-decline');
var manageLinks = document.querySelectorAll('[data-open-cookie-preferences]'); var manageLinks = document.querySelectorAll('[data-open-cookie-preferences]');
if (acceptBtn) { if (acknowledgeBtn) {
acceptBtn.addEventListener('click', acceptTracking); acknowledgeBtn.addEventListener('click', acknowledgeNotice);
}
if (declineBtn) {
declineBtn.addEventListener('click', declineTracking);
} }
manageLinks.forEach(function (link) { manageLinks.forEach(function (link) {
link.addEventListener('click', function (event) { link.addEventListener('click', function (event) {
@@ -140,16 +142,14 @@
}); });
}; };
function initConsent() { function initNotice() {
bindBannerControls(); bindBannerControls();
loadTracker();
var consent = getConsent(); var migration = migrateLegacyConsent();
if (consent === 'accepted') { var notice = getStorageItem(NOTICE_KEY);
hideBanner();
loadTracker(); if (notice === 'acknowledged' || migration === 'migrated_acknowledged') {
return;
}
if (consent === 'declined') {
hideBanner(); hideBanner();
return; return;
} }
@@ -157,5 +157,5 @@
showBanner(); showBanner();
} }
initConsent(); initNotice();
})(); })();
@@ -0,0 +1,286 @@
(function () {
'use strict';
var configEl = document.getElementById('webmcp-config');
if (!configEl) {
return;
}
var modelContext = navigator.modelContext || document.modelContext;
if (!modelContext || typeof modelContext.registerTool !== 'function') {
return;
}
var contactUrl = configEl.dataset.contactUrl || '/contact';
var pageName = configEl.dataset.pageName || '';
var recaptchaRequired = configEl.dataset.recaptchaRequired === 'true';
var services = [];
var pages = {};
try {
services = JSON.parse(configEl.dataset.services || '[]');
} catch (e) {
services = [];
}
try {
pages = JSON.parse(configEl.dataset.pages || '{}');
} catch (e) {
pages = {};
}
function textResult(payload) {
return {
content: [{
type: 'text',
text: typeof payload === 'string' ? payload : JSON.stringify(payload, null, 2),
}],
};
}
function getCsrfToken() {
var match = document.cookie.match(/(?:^|;\s*)csrftoken=([^;]+)/);
return match ? decodeURIComponent(match[1]) : '';
}
function resolvePageUrl(slug) {
var page = pages[slug];
if (page && page.url) {
return page.url;
}
var service = services.find(function (entry) {
return entry.slug === slug;
});
return service ? service.url : null;
}
function findPage(query) {
var normalized = String(query || '').trim().toLowerCase();
if (!normalized) {
return null;
}
var keys = Object.keys(pages);
for (var i = 0; i < keys.length; i += 1) {
var slug = keys[i];
var page = pages[slug];
if (
slug.toLowerCase() === normalized ||
page.name.toLowerCase() === normalized ||
page.name.toLowerCase().indexOf(normalized) !== -1
) {
return {
slug: slug,
name: page.name,
summary: page.summary,
url: page.url || resolvePageUrl(slug),
};
}
}
return null;
}
async function getRecaptchaToken() {
if (!recaptchaRequired) {
return null;
}
if (!window.grecaptcha || typeof window.grecaptcha.execute !== 'function') {
throw new Error(
'reCAPTCHA is required in production. Use the HTML contact form in a browser session, ' +
'or enable DEBUG for audit demos without captcha.'
);
}
var widget = document.querySelector('.g-recaptcha');
var siteKey = widget ? widget.getAttribute('data-sitekey') : null;
if (!siteKey) {
var script = document.querySelector('script[src*="recaptcha"]');
if (script && script.src) {
var keyMatch = script.src.match(/[?&]render=([^&]+)/);
siteKey = keyMatch ? decodeURIComponent(keyMatch[1]) : null;
}
}
if (!siteKey) {
throw new Error('reCAPTCHA site key not found on the contact page.');
}
return window.grecaptcha.execute(siteKey, { action: 'contact' });
}
async function submitContactInquiry(input) {
var formData = new FormData();
formData.append('name', input.name);
formData.append('email', input.email);
formData.append('subject', input.subject);
formData.append('message', input.message || '');
formData.append('csrfmiddlewaretoken', getCsrfToken());
if (recaptchaRequired) {
var token = await getRecaptchaToken();
formData.append('g-recaptcha-response', token);
}
var response = await fetch(contactUrl, {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: {
'X-Requested-With': 'XMLHttpRequest',
},
});
var html = await response.text();
if (html.indexOf("We'll be in contact shortly!") !== -1) {
return textResult({
success: true,
message: 'Contact inquiry submitted successfully.',
});
}
if (html.indexOf('There was an error submitting') !== -1) {
return textResult({
success: false,
error: 'Submission failed validation (often reCAPTCHA in production).',
});
}
if (html.indexOf('is required') !== -1) {
return textResult({
success: false,
error: 'Missing required fields: name, email, and subject are required.',
});
}
return textResult({
success: false,
error: 'Unexpected response from contact endpoint.',
status: response.status,
});
}
modelContext.registerTool({
name: 'list_services',
description: 'List AI ML Operations service pages with name, URL, and summary.',
inputSchema: {
type: 'object',
properties: {},
},
annotations: { readOnlyHint: true },
execute: function () {
var results = services.map(function (service) {
return {
name: service.name,
slug: service.slug,
url: service.url || resolvePageUrl(service.slug),
summary: service.summary,
};
});
return textResult({ services: results });
},
});
modelContext.registerTool({
name: 'get_page_content',
description: 'Look up a public page by slug or service name and return its URL and summary.',
inputSchema: {
type: 'object',
properties: {
query: {
type: 'string',
description: 'Page slug (e.g. forward_deployed) or service name (e.g. AI Agents).',
},
},
required: ['query'],
},
annotations: { readOnlyHint: true },
execute: function (input) {
var page = findPage(input.query);
if (!page) {
return textResult({
success: false,
error: 'No page found for query: ' + input.query,
});
}
return textResult({ success: true, page: page });
},
});
modelContext.registerTool({
name: 'navigate_to_service',
description: 'Resolve a service slug or name to its canonical marketing page URL.',
inputSchema: {
type: 'object',
properties: {
service: {
type: 'string',
description: 'Service slug or display name.',
},
},
required: ['service'],
},
annotations: { readOnlyHint: true },
execute: function (input) {
var page = findPage(input.service);
if (!page) {
return textResult({
success: false,
error: 'Unknown service: ' + input.service,
});
}
return textResult({
success: true,
url: page.url,
name: page.name,
summary: page.summary,
});
},
});
modelContext.registerTool({
name: 'open_contact_with_subject',
description: 'Build a contact page URL with a pre-filled subject for hosting, pricing, or service inquiries.',
inputSchema: {
type: 'object',
properties: {
subject: {
type: 'string',
description: 'Subject line to pre-fill on the contact form.',
},
},
required: ['subject'],
},
annotations: { readOnlyHint: true },
execute: function (input) {
var url = new URL(contactUrl, window.location.origin);
url.searchParams.set('subject', input.subject);
return textResult({
success: true,
url: url.toString(),
subject: input.subject,
});
},
});
if (pageName === 'contact') {
modelContext.registerTool({
name: 'submit_contact_inquiry',
description: 'Submit a contact inquiry to AI ML Operations. Requires name, email, and subject.',
inputSchema: {
type: 'object',
properties: {
name: { type: 'string', description: 'Sender full name.' },
email: { type: 'string', description: 'Sender email address.' },
subject: { type: 'string', description: 'Inquiry subject line.' },
message: { type: 'string', description: 'Optional message body.' },
},
required: ['name', 'email', 'subject'],
},
annotations: { readOnlyHint: false },
execute: submitContactInquiry,
});
}
})();
+75 -20
View File
@@ -44,23 +44,33 @@
{% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %} {% if user.is_authenticated %}data-user-id="{{ user.pk }}"{% endif %}
hidden></div> hidden></div>
{% endif %} {% endif %}
{% if webmcp_enabled %}
<div id="webmcp-config"
data-page-name="{{ webmcp_page_name }}"
data-contact-url="{{ webmcp_contact_url }}"
data-recaptcha-required="{{ webmcp_recaptcha_required|yesno:'true,false' }}"
data-services='{{ webmcp_services_json|escapejs }}'
data-pages='{{ webmcp_pages_json|escapejs }}'
hidden></div>
{% endif %}
</head> </head>
<body> <body>
<!-- Navigation Bar --> <!-- Navigation Bar -->
<nav> <nav aria-label="Main navigation">
<a href="{% url 'public_index' %}" class="brand-logo"> <a href="{% url 'public_index' %}" class="brand-logo">
<img src="{% static 'public/img/logo.png' %}" alt="AI ML Operations" class="brand-logo-img" width="243" height="28"> <img src="{% static 'public/img/logo.png' %}" alt="AI ML Operations" class="brand-logo-img" width="243" height="28">
</a> </a>
<button class="mobile-menu-btn" aria-label="Menu"></button> <button type="button" class="mobile-menu-btn" aria-label="Open menu" aria-expanded="false" aria-controls="main-nav-links"></button>
<ul class="nav-links"> <ul class="nav-links" id="main-nav-links">
<li><a href="{% url 'public_index' %}" <li><a href="{% url 'public_index' %}"
class="{% if request.resolver_match.url_name == 'public_index' %}active{% endif %}">Home</a></li> class="{% if request.resolver_match.url_name == 'public_index' %}active{% endif %}">Home</a></li>
<li class="dropdown"> <li class="dropdown">
<a href="#" <button type="button" class="nav-dropdown-trigger{% if request.resolver_match.url_name in 'forward_deployed,ai_education,ai_sensor,bot,chat,computers,file_hosting,ml_model,web_design' %} active{% endif %}"
class="{% if request.resolver_match.url_name in 'forward_deployed,ai_education,ai_sensor,bot,chat,computers,file_hosting,ml_model,web_design' %}active{% endif %}">Services</a> id="services-menu-button" aria-expanded="false" aria-haspopup="true" aria-controls="services-menu">Services</button>
<ul class="dropdown-content"> <ul class="dropdown-content" id="services-menu" role="menu" aria-labelledby="services-menu-button">
<li><a href="{% url 'forward_deployed' %}">Forward-Deployed AI</a></li> <li><a href="{% url 'forward_deployed' %}">Forward-Deployed AI</a></li>
<li><a href="{% url 'bot' %}">AI Agents</a></li> <li><a href="{% url 'bot' %}">AI Agents</a></li>
<li><a href="{% url 'ml_model' %}">ML Models</a></li> <li><a href="{% url 'ml_model' %}">ML Models</a></li>
@@ -79,19 +89,28 @@
<li><a href="{% url 'planning:board_view' %}" <li><a href="{% url 'planning:board_view' %}"
class="{% if 'planning' in request.path %}active{% endif %}" class="{% if 'planning' in request.path %}active{% endif %}"
data-tianji-event="nav_planning">Planning</a></li> data-tianji-event="nav_planning">Planning</a></li>
<li><a href="{% url 'financial_index' %}" {% if has_financial_access %}
<li><a href="{% url 'financial_home' %}"
class="{% if 'financial' in request.path %}active{% endif %}" class="{% if 'financial' in request.path %}active{% endif %}"
data-tianji-event="nav_financials">Financials</a></li> data-tianji-event="nav_financials">Financials</a></li>
{% endif %}
<li class="dropdown" id="user-profile-dropdown"> <li class="dropdown" id="user-profile-dropdown">
<a href="#" class="profile-icon-link" title="{{ user.get_full_name|default:user.username }}"> <button type="button" class="profile-icon-link" aria-label="Account menu for {{ user.get_full_name|default:user.username }}"
aria-expanded="false" aria-haspopup="true" aria-controls="profile-menu">
<svg class="profile-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" <svg class="profile-icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="8" r="4" /> <circle cx="12" cy="8" r="4" />
<path d="M4 21v-1a6 6 0 0 1 12 0v1" /> <path d="M4 21v-1a6 6 0 0 1 12 0v1" />
</svg> </svg>
</a> </button>
<ul class="dropdown-content profile-dropdown-content"> <ul class="dropdown-content profile-dropdown-content" id="profile-menu" role="menu">
<li class="profile-name-item">{{ user.get_full_name|default:user.username }}</li> <li class="profile-name-item">{{ user.get_full_name|default:user.username }}</li>
{% if has_financial_access %}
<li><a href="{% url 'profile' %}">Profile</a></li>
{% endif %}
{% if is_financial_admin %}
<li><a href="{% url 'manage_users' %}">Manage Users</a></li>
{% endif %}
<li><a href="{% url 'change_password' %}">Change Password</a></li> <li><a href="{% url 'change_password' %}">Change Password</a></li>
<li> <li>
<form action="{% url 'logout' %}" method="post" style="margin: 0;"> <form action="{% url 'logout' %}" method="post" style="margin: 0;">
@@ -123,7 +142,7 @@
<a href="{% url 'terms_of_service' %}" data-tianji-event="footer_terms">Terms &amp; Privacy</a> <a href="{% url 'terms_of_service' %}" data-tianji-event="footer_terms">Terms &amp; Privacy</a>
{% if tianji_enabled %} {% if tianji_enabled %}
<span class="footer-separator">|</span> <span class="footer-separator">|</span>
<a href="#" data-open-cookie-preferences data-tianji-event="footer_cookie_preferences">Cookie Preferences</a> <button type="button" class="footer-link-btn" data-open-cookie-preferences data-tianji-event="footer_cookie_preferences">Cookie Preferences</button>
{% endif %} {% endif %}
</p> </p>
<p class="footer-text footer-text--muted">&copy; 2023 - <p class="footer-text footer-text--muted">&copy; 2023 -
@@ -133,23 +152,27 @@
</footer> </footer>
{% if tianji_enabled %} {% if tianji_enabled %}
<div id="cookie-consent-banner" class="cookie-consent-banner" hidden role="dialog" aria-live="polite" <div id="cookie-consent-banner" class="cookie-consent-banner" hidden role="dialog" aria-modal="true"
aria-label="Cookie consent"> aria-labelledby="cookie-consent-title" aria-describedby="cookie-consent-description">
<div class="cookie-consent-content"> <div class="cookie-consent-content">
<p class="cookie-consent-text"> <h2 id="cookie-consent-title" class="visually-hidden">Analytics notice</h2>
<span class="cookie-consent-text-full">We use analytics tracking to understand how visitors use our site. Tracking runs only if you accept. <p class="cookie-consent-text" id="cookie-consent-description">
<span class="cookie-consent-text-full">We use analytics to understand how visitors use our site. This helps us improve performance and content.
See our <a href="{% url 'terms_of_service' %}">Terms of Service &amp; Privacy Policy</a> for details.</span> See our <a href="{% url 'terms_of_service' %}">Terms of Service &amp; Privacy Policy</a> for details.</span>
<span class="cookie-consent-text-short">We use analytics if you accept. <a href="{% url 'terms_of_service' %}">Privacy Policy</a></span> <span class="cookie-consent-text-short">We use analytics on this site. <a href="{% url 'terms_of_service' %}">Privacy Policy</a></span>
</p> </p>
<div class="cookie-consent-actions"> <div class="cookie-consent-actions">
<button type="button" id="cookie-consent-decline" class="btn btn-outline">Decline</button> <button type="button" id="cookie-consent-acknowledge" class="btn">Acknowledge</button>
<button type="button" id="cookie-consent-accept" class="btn">Accept Analytics</button>
</div> </div>
</div> </div>
</div> </div>
<script src="{% static 'public/js/tianji-consent.js' %}"></script> <script src="{% static 'public/js/tianji-consent.js' %}"></script>
{% endif %} {% endif %}
{% if webmcp_enabled %}
<script src="{% static 'public/js/webmcp-tools.js' %}"></script>
{% endif %}
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
const mobileBtn = document.querySelector('.mobile-menu-btn'); const mobileBtn = document.querySelector('.mobile-menu-btn');
@@ -157,9 +180,41 @@
if (mobileBtn && navLinks) { if (mobileBtn && navLinks) {
mobileBtn.addEventListener('click', function () { mobileBtn.addEventListener('click', function () {
navLinks.classList.toggle('active'); const isOpen = navLinks.classList.toggle('active');
mobileBtn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
mobileBtn.setAttribute('aria-label', isOpen ? 'Close menu' : 'Open menu');
}); });
} }
function closeDropdown(dropdown) {
dropdown.classList.remove('dropdown-open');
const trigger = dropdown.querySelector('.nav-dropdown-trigger, .profile-icon-link');
if (trigger) {
trigger.setAttribute('aria-expanded', 'false');
}
}
document.querySelectorAll('.dropdown').forEach(function (dropdown) {
const trigger = dropdown.querySelector('.nav-dropdown-trigger, .profile-icon-link');
if (!trigger) {
return;
}
trigger.addEventListener('click', function (event) {
if (window.matchMedia('(min-width: 769px)').matches && dropdown.querySelector('.nav-dropdown-trigger')) {
return;
}
event.preventDefault();
const isOpen = dropdown.classList.toggle('dropdown-open');
trigger.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
});
});
document.addEventListener('click', function (event) {
if (!event.target.closest('.dropdown')) {
document.querySelectorAll('.dropdown.dropdown-open').forEach(closeDropdown);
}
});
}); });
</script> </script>
{% block tracking_events %}{% endblock %} {% block tracking_events %}{% endblock %}
@@ -0,0 +1,8 @@
{% include "django_recaptcha/includes/js_v3.html" %}
<input
type="hidden"
name="{{ widget.name }}"
class="g-recaptcha"
form="contact-form"
{% for name, value in widget.attrs.items %}{% if value is not False %} {{ name }}{% if value is not True %}="{{ value|stringformat:'s' }}"{% endif %}{% endif %}{% endfor %}
>
@@ -6,7 +6,7 @@
{% block content %} {% block content %}
<!-- Hero Section --> <!-- Hero Section -->
<div class="hero-section" style="height: 40vh; min-height: 300px;"> <div class="hero-section hero-section--compact">
<div class="hero-content"> <div class="hero-content">
<h1 class="hero-title">Get in Touch</h1> <h1 class="hero-title">Get in Touch</h1>
<p class="hero-subtitle hero-subtitle--wide">Describe the workflow or bottleneck you want to automate—we will help you scope a forward-deployed solution.</p> <p class="hero-subtitle hero-subtitle--wide">Describe the workflow or bottleneck you want to automate—we will help you scope a forward-deployed solution.</p>
@@ -32,52 +32,69 @@
{% endif %} {% endif %}
<div class="card"> <div class="card">
<h4 class="card-title" style="margin-bottom: 2rem;">Send Us a Message</h4> <h2 class="card-title contact-form-heading" id="contact-form-heading" style="margin-bottom: 2rem;">Send Us a Message</h2>
<form action="{% url 'contact' %}" method="POST"> <form id="contact-form" action="{% url 'contact' %}" method="POST" aria-labelledby="contact-form-heading"
toolname="submit_contact_inquiry"
tooldescription="Submit a contact inquiry to AI ML Operations">
{% csrf_token %} {% csrf_token %}
<div class="form-row"> <div class="form-row">
<div class="form-group"> <div class="form-group">
<input type="text" class="form-control" name="name" placeholder="Your Name"> <label class="form-label" for="contact-name">Your Name</label>
<input type="text" class="form-control" name="name" id="contact-name" autocomplete="name" required
toolparamdescription="Full name of the person submitting the inquiry.">
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="email" class="form-control" name="email" placeholder="Your Email"> <label class="form-label" for="contact-email">Your Email</label>
<input type="email" class="form-control" name="email" id="contact-email" autocomplete="email" required
toolparamdescription="Email address where AI ML Operations can reply.">
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<input type="text" class="form-control" name="subject" id="contact-subject" placeholder="Subject" value="{{ request.GET.subject|default:'' }}"> <label class="form-label" for="contact-subject">Subject</label>
<input type="text" class="form-control" name="subject" id="contact-subject" value="{{ request.GET.subject|default:'' }}" required
toolparamdescription="Short summary of the inquiry topic or service of interest.">
</div> </div>
<div class="form-group"> <div class="form-group">
<textarea name="message" class="form-control" rows="5" placeholder="What workflow is costing you the most time? What systems does it touch?"></textarea> <label class="form-label" for="contact-message">Message</label>
</div> <textarea name="message" class="form-control" id="contact-message" rows="5"
placeholder="What workflow is costing you the most time? What systems does it touch?"
<div class="form-group"> toolparamdescription="Optional details about the workflow, bottleneck, or systems involved."></textarea>
{% if capchaForm %}
{{ capchaForm }}
{% endif %}
</div> </div>
{% if not capchaForm %}
<button class="btn" type="submit" <button class="btn" type="submit"
data-tianji-event="contact_form_submit"> data-tianji-event="contact_form_submit">
Send Message Send Message
</button> </button>
{% endif %}
</form> </form>
{% if capchaForm %}
<div class="form-group contact-captcha-group">
{{ capchaForm }}
</div>
<button class="btn" type="submit" form="contact-form"
data-tianji-event="contact_form_submit">
Send Message
</button>
{% endif %}
</div> </div>
</div> </div>
<!-- Info Column --> <!-- Info Column -->
<div> <div>
<div class="card"> <div class="card">
<h5 class="card-title">Contact Information</h5> <h3 class="card-title">Contact Information</h3>
<ul style="list-style: none;"> <ul class="contact-info-list">
<li style="margin-bottom: 1rem; display: flex; align-items: center; gap: 1rem;"> <li class="contact-info-item">
<span style="color: var(--primary-color);">📞</span> <span class="contact-info-icon" aria-hidden="true">📞</span>
<p style="color: var(--text-muted);">+1 (330) 402-2675</p> <a href="tel:+13304022675">+1 (330) 402-2675</a>
</li> </li>
<li style="margin-bottom: 1rem; display: flex; align-items: center; gap: 1rem;"> <li class="contact-info-item">
<span style="color: var(--primary-color);">✉️</span> <span class="contact-info-icon" aria-hidden="true">✉️</span>
<p style="color: var(--text-muted);">ryan@aimloperations.com</p> <a href="mailto:ryan@aimloperations.com">ryan@aimloperations.com</a>
</li> </li>
</ul> </ul>
</div> </div>
@@ -106,13 +106,16 @@
--> -->
<div class="form-group col-6" style="padding-left:1rem"> <div class="form-group col-6" style="padding-left:1rem">
<input type="email" class="form-control" name="email" placeholder="Your Email"> <label for="legacy-contact-email">Your Email</label>
<input type="email" class="form-control" name="email" id="legacy-contact-email" placeholder="Your Email">
</div> </div>
<div class="form-group col-6" style="padding-right:1rem"> <div class="form-group col-6" style="padding-right:1rem">
<input type="text" class="form-control" name="name" placeholder="Your Name"> <label for="legacy-contact-name">Your Name</label>
<input type="text" class="form-control" name="name" id="legacy-contact-name" placeholder="Your Name">
</div> </div>
<div class="form-group col-12" style="padding:1rem"> <div class="form-group col-12" style="padding:1rem">
<textarea name="message" type="text" class="form-control" rows="5" placeholder="Your message"></textarea> <label for="legacy-contact-message">Your Message</label>
<textarea name="message" id="legacy-contact-message" class="form-control" rows="5" placeholder="Your message"></textarea>
</div> </div>
<div class="form-group col-12"> <div class="form-group col-12">
{% if capchaForm %} {% if capchaForm %}
@@ -0,0 +1,19 @@
# AI ML Operations, LLC
> Forward-deployed AI engineering. We embed with your team to architect, build, and deploy custom agentic workflows, integrations, and production AI systems.
AI ML Operations helps organizations move from AI pilots to production systems. Core services include forward-deployed AI engineering, custom AI agents, ML model development, secure hosted chat, sensor algorithms, education, hardware builds, and web hosting.
## Key pages
{% for page in pages %}- [{{ page.title }}]({{ page.url }})
{% endfor %}
## Contact
- [Contact form]({{ contact_url }})
- Phone: +1 (330) 402-2675
- Email: ryan@aimloperations.com
## Site
{{ site_url }}
@@ -11,7 +11,7 @@
{% block content %} {% block content %}
<!-- Hero Section --> <!-- Hero Section -->
<div class="hero-section"> <div class="hero-section">
<canvas id="hero-canvas"></canvas> <canvas id="hero-canvas" aria-hidden="true"></canvas>
<div class="hero-content"> <div class="hero-content">
<h1 class="hero-title">AI ML Operations</h1> <h1 class="hero-title">AI ML Operations</h1>
<p class="hero-subtitle hero-subtitle--wide"> <p class="hero-subtitle hero-subtitle--wide">
@@ -0,0 +1,20 @@
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /accounts/
Disallow: /financial/
Disallow: /planning/
User-agent: GPTBot
Allow: /
User-agent: ClaudeBot
Allow: /
User-agent: Google-Extended
Allow: /
User-agent: PerplexityBot
Allow: /
Sitemap: {{ sitemap_url }}
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
{% for page in pages %}
<url>
<loc>{{ page.loc }}</loc>
<changefreq>{{ page.changefreq }}</changefreq>
<priority>{{ page.priority }}</priority>
</url>
{% endfor %}
</urlset>
@@ -52,7 +52,7 @@
<ul> <ul>
<li><strong>Information you provide:</strong> name, email address, message content, and other details submitted through contact forms or account-related requests.</li> <li><strong>Information you provide:</strong> name, email address, message content, and other details submitted through contact forms or account-related requests.</li>
<li><strong>Account information:</strong> username and authentication data for authorized users of internal tools.</li> <li><strong>Account information:</strong> username and authentication data for authorized users of internal tools.</li>
<li><strong>Usage and analytics data:</strong> if you accept analytics in our cookie banner, we collect information about how you interact with the site, as described in Section 4.2.</li> <li><strong>Usage and analytics data:</strong> information about how you interact with the site, as described in Section 4.2.</li>
<li><strong>Technical information:</strong> browser type, device type, operating system, language, referring URLs, and similar data collected automatically when you use the site.</li> <li><strong>Technical information:</strong> browser type, device type, operating system, language, referring URLs, and similar data collected automatically when you use the site.</li>
</ul> </ul>
<p> <p>
@@ -63,9 +63,9 @@
<h3>4.2 Analytics and Usage Tracking</h3> <h3>4.2 Analytics and Usage Tracking</h3>
<p> <p>
We use Tianji, a self-hosted analytics platform, to understand how visitors use our website and internal We use Tianji, a self-hosted analytics platform, to understand how visitors use our website and internal
tools. Tracking is enabled only after you provide consent through our cookie banner. tools. Analytics run when you use the site so we can measure usage and improve the experience.
</p> </p>
<p>When you accept analytics, we may collect information such as:</p> <p>We may collect information such as:</p>
<ul> <ul>
<li>Pages viewed and navigation paths</li> <li>Pages viewed and navigation paths</li>
<li>Approximate geographic location derived from truncated IP addresses</li> <li>Approximate geographic location derived from truncated IP addresses</li>
@@ -76,16 +76,16 @@
</ul> </ul>
<p> <p>
We do not use Tianji to sell your personal information. Analytics data helps us improve site performance, We do not use Tianji to sell your personal information. Analytics data helps us improve site performance,
content, and product usability. You may decline analytics at any time using the cookie preferences link content, and product usability. We show an analytics notice when you first visit the site. You can review it
in the site footer. If you decline, the analytics script will not load. again at any time using the cookie preferences link in the site footer.
</p> </p>
<p> <p>
We store your consent choice locally in your browser so we can remember your preference on future visits. We store your acknowledgement locally in your browser so we do not show the notice on every visit.
</p> </p>
<h3>4.3 Cookies and Local Storage</h3> <h3>4.3 Cookies and Local Storage</h3>
<p> <p>
In addition to analytics consent storage, our site uses essential cookies and session storage required for In addition to analytics notice storage, our site uses essential cookies and session storage required for
authentication, security (including CSRF protection), and basic site functionality. These essential authentication, security (including CSRF protection), and basic site functionality. These essential
technologies are necessary for the site to operate and are not used for marketing analytics. technologies are necessary for the site to operate and are not used for marketing analytics.
</p> </p>
@@ -126,7 +126,7 @@
<h3>4.7 Your Choices and Rights</h3> <h3>4.7 Your Choices and Rights</h3>
<p>You can:</p> <p>You can:</p>
<ul> <ul>
<li>Accept or decline analytics tracking through our cookie banner or the cookie preferences link in the footer</li> <li>Acknowledge our analytics notice through the banner or review it again using the cookie preferences link in the footer</li>
<li>Contact us to request access to, correction of, or deletion of personal information you have provided, subject to applicable law</li> <li>Contact us to request access to, correction of, or deletion of personal information you have provided, subject to applicable law</li>
<li>Disable non-essential browser storage or cookies through your browser settings, though essential site features may not function properly</li> <li>Disable non-essential browser storage or cookies through your browser settings, though essential site features may not function properly</li>
</ul> </ul>
@@ -97,7 +97,7 @@ visually stunning, functional websites tailored to your business.{% endblock %}
<div class="pricing-toggle" role="group" aria-label="Billing period"> <div class="pricing-toggle" role="group" aria-label="Billing period">
<span class="pricing-toggle-label active" id="monthlyLabel">Monthly</span> <span class="pricing-toggle-label active" id="monthlyLabel">Monthly</span>
<label class="pricing-switch" for="pricingToggle"> <label class="pricing-switch" for="pricingToggle">
<input type="checkbox" id="pricingToggle" role="switch" aria-labelledby="monthlyLabel yearlyLabel"> <input type="checkbox" id="pricingToggle" aria-label="Toggle between monthly and yearly billing">
<span class="pricing-switch-slider"></span> <span class="pricing-switch-slider"></span>
</label> </label>
<span class="pricing-toggle-label" id="yearlyLabel">Yearly</span> <span class="pricing-toggle-label" id="yearlyLabel">Yearly</span>
@@ -119,7 +119,7 @@ visually stunning, functional websites tailored to your business.{% endblock %}
<li style="margin-bottom: 0.5rem;">✓ Email Notifications</li> <li style="margin-bottom: 0.5rem;">✓ Email Notifications</li>
<li style="margin-bottom: 0.5rem;">✓ Backend Admin Access</li> <li style="margin-bottom: 0.5rem;">✓ Backend Admin Access</li>
</ul> </ul>
<a href="#" class="btn">Get Started</a> <a href="{% url 'contact' %}?subject=Web%20Hosting%20Standard%20Plan" class="btn">Get Started</a>
</div> </div>
<!-- Yearly Card --> <!-- Yearly Card -->
@@ -135,7 +135,7 @@ visually stunning, functional websites tailored to your business.{% endblock %}
<li style="margin-bottom: 0.5rem;">✓ 2 Months Free (Yearly)</li> <li style="margin-bottom: 0.5rem;">✓ 2 Months Free (Yearly)</li>
<li style="margin-bottom: 0.5rem;">✓ Priority Support</li> <li style="margin-bottom: 0.5rem;">✓ Priority Support</li>
</ul> </ul>
<a href="#" class="btn" style="background: var(--secondary-color); color: white;">Save 20%</a> <a href="{% url 'contact' %}?subject=Web%20Hosting%20Premium%20Plan" class="btn" style="background: var(--secondary-color); color: white;">Save 20%</a>
</div> </div>
</div> </div>
</div> </div>
+151 -6
View File
@@ -1,9 +1,38 @@
from unittest.mock import patch from unittest.mock import patch
from django.contrib.auth.models import User
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from .models import Contact from .models import Contact, EmailMessage
from .seo import SERVICE_URL_NAMES, get_service_entries
class PreviewEmailAuthTests(TestCase):
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(username="previewer", password="pass")
self.email = EmailMessage.objects.create(
subject="Preview subject",
body="Preview body content",
recipient="recipient@example.com",
)
self.url = reverse("preview_email", kwargs={"pk": self.email.pk})
def test_unauthenticated_user_is_redirected_to_login(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 302)
self.assertIn("/accounts/login/", response.url)
def test_authenticated_user_can_preview_email(self):
self.client.login(username="previewer", password="pass")
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "Preview subject")
self.assertContains(response, "Preview body content")
@override_settings( @override_settings(
@@ -26,9 +55,14 @@ class ContactViewTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "Send Us a Message") self.assertContains(response, "Send Us a Message")
self.assertContains(response, 'name="name"') self.assertContains(response, 'id="contact-name"')
self.assertContains(response, 'name="email"') self.assertContains(response, 'for="contact-name"')
self.assertContains(response, 'name="subject"') self.assertContains(response, 'id="contact-email"')
self.assertContains(response, 'for="contact-email"')
self.assertContains(response, 'id="contact-subject"')
self.assertContains(response, 'for="contact-subject"')
self.assertContains(response, 'id="contact-message"')
self.assertContains(response, 'for="contact-message"')
@patch("public.views.send_contact_email") @patch("public.views.send_contact_email")
def test_contact_post_success_saves_contact_and_sends_email(self, mock_send_email): def test_contact_post_success_saves_contact_and_sends_email(self, mock_send_email):
@@ -110,18 +144,129 @@ class ContactViewTests(TestCase):
@override_settings(DEBUG=True, TIANJI_ENABLED=True) @override_settings(DEBUG=True, TIANJI_ENABLED=True)
class TianjiTrackingTests(TestCase): class TianjiTrackingTests(TestCase):
def test_homepage_includes_consent_banner_when_enabled(self): def test_homepage_includes_analytics_notice_when_enabled(self):
response = self.client.get(reverse("public_index")) response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertContains(response, "cookie-consent-banner") self.assertContains(response, "cookie-consent-banner")
self.assertContains(response, "cookie-consent-acknowledge")
self.assertContains(response, "Acknowledge")
self.assertNotContains(response, "cookie-consent-decline")
self.assertNotContains(response, "Accept Analytics")
self.assertContains(response, "tianji-config") self.assertContains(response, "tianji-config")
self.assertContains(response, "tianji-consent.js") self.assertContains(response, "tianji-consent.js")
@override_settings(TIANJI_ENABLED=False) @override_settings(TIANJI_ENABLED=False)
def test_homepage_omits_consent_banner_when_disabled(self): def test_homepage_omits_analytics_notice_when_disabled(self):
response = self.client.get(reverse("public_index")) response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "cookie-consent-banner") self.assertNotContains(response, "cookie-consent-banner")
self.assertNotContains(response, "tianji-config") self.assertNotContains(response, "tianji-config")
class AgenticBrowsingSeoTests(TestCase):
def test_robots_txt_is_available(self):
response = self.client.get(reverse("robots_txt"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
self.assertContains(response, "User-agent: *")
self.assertContains(response, "Sitemap:")
def test_sitemap_xml_lists_public_pages(self):
response = self.client.get(reverse("sitemap_xml"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/xml; charset=utf-8")
self.assertContains(response, "<urlset")
self.assertContains(response, reverse("contact"))
self.assertContains(response, reverse("forward_deployed"))
def test_llms_txt_is_available(self):
response = self.client.get(reverse("llms_txt"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "text/plain; charset=utf-8")
self.assertContains(response, "# AI ML Operations, LLC")
self.assertContains(response, "## Key pages")
self.assertContains(response, reverse("contact"))
def test_homepage_uses_semantic_nav_controls(self):
response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'id="services-menu-button"')
self.assertContains(response, 'aria-haspopup="true"')
self.assertContains(response, 'aria-label="Main navigation"')
@override_settings(DEBUG=True, WEBMCP_ENABLED=True)
class WebMcpTests(TestCase):
def test_homepage_includes_webmcp_when_enabled(self):
response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "webmcp-config")
self.assertContains(response, "webmcp-tools.js")
self.assertContains(response, 'data-page-name="public_index"')
self.assertContains(response, "forward_deployed")
@override_settings(WEBMCP_ENABLED=False)
def test_homepage_omits_webmcp_when_disabled(self):
response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "webmcp-config")
self.assertNotContains(response, "webmcp-tools.js")
def test_contact_page_includes_webmcp_form_annotations(self):
response = self.client.get(reverse("contact"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'id="contact-form"')
self.assertContains(response, 'toolname="submit_contact_inquiry"')
self.assertContains(response, "tooldescription=")
self.assertContains(response, "toolparamdescription=")
self.assertContains(response, "submit_contact_inquiry")
@override_settings(DEBUG=False)
def test_contact_page_renders_captcha_outside_annotated_form(self):
response = self.client.get(reverse("contact"))
self.assertEqual(response.status_code, 200)
form_end = response.content.index(b"</form>")
captcha_index = response.content.index(b"g-recaptcha")
self.assertGreater(captcha_index, form_end)
self.assertContains(response, b'form="contact-form"')
def test_webmcp_services_json_includes_all_service_pages(self):
response = self.client.get(reverse("public_index"))
self.assertEqual(response.status_code, 200)
for slug in SERVICE_URL_NAMES:
self.assertContains(response, slug)
def test_service_entries_match_public_seo_config(self):
entries = get_service_entries()
self.assertEqual(len(entries), len(SERVICE_URL_NAMES))
self.assertEqual({entry["slug"] for entry in entries}, SERVICE_URL_NAMES)
def test_webmcp_static_script_defines_expected_tools(self):
from pathlib import Path
script_path = Path(__file__).resolve().parent / "static/public/js/webmcp-tools.js"
script = script_path.read_text(encoding="utf-8")
for tool_name in (
"list_services",
"get_page_content",
"navigate_to_service",
"open_contact_with_subject",
"submit_contact_inquiry",
):
self.assertIn("name: '" + tool_name + "'", script)
self.assertIn("readOnlyHint: true", script)
self.assertIn("readOnlyHint: false", script)
self.assertIn("navigator.modelContext || document.modelContext", script)
+4 -1
View File
@@ -1,8 +1,11 @@
from django.urls import path from django.urls import path
from . import views from . import seo, views
urlpatterns = [ urlpatterns = [
path("robots.txt", seo.robots_txt, name="robots_txt"),
path("sitemap.xml", seo.sitemap_xml, name="sitemap_xml"),
path("llms.txt", seo.llms_txt, name="llms_txt"),
path("", views.index, name="public_index"), path("", views.index, name="public_index"),
path("chat", views.chat, name="chat"), path("chat", views.chat, name="chat"),
path("ai_education", views.ai_education, name="ai_education"), path("ai_education", views.ai_education, name="ai_education"),
+7 -4
View File
@@ -18,11 +18,14 @@ services:
build: . build: .
ports: ports:
- "8000:8000" - "8000:8000"
env_file: # No required env_file — CI has no .env. Defaults below; for local secrets:
- .env # docker compose --env-file .env up
environment: environment:
DJANGO_ENV: dev DJANGO_ENV: ${DJANGO_ENV:-dev}
DATABASE_URL: postgres://company_site:company_site@db:5432/company_site DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
DJANGO_DEBUG: ${DJANGO_DEBUG:-true}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,0.0.0.0}
DATABASE_URL: ${DATABASE_URL:-postgres://company_site:company_site@db:5432/company_site}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy