Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5275f1b7e |
+32
-18
@@ -1,21 +1,39 @@
|
|||||||
name: Deploy Company Site
|
name: Deploy Company Site
|
||||||
|
|
||||||
# Runs after Unit Tests completes on master. Direct pushes only (not PRs).
|
# Deploy pipeline runs only on pushes to master (never on pull requests).
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
push:
|
||||||
workflows: [Unit Tests]
|
|
||||||
types: [completed]
|
|
||||||
branches: [master]
|
branches: [master]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
test:
|
||||||
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
|
||||||
ref: ${{ gitea.event.workflow_run.head_sha }}
|
- 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:
|
||||||
|
runs-on: self-hosted
|
||||||
|
needs: test
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
run: docker compose build
|
run: docker compose build
|
||||||
@@ -31,15 +49,11 @@ jobs:
|
|||||||
docker compose down
|
docker compose down
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
if: gitea.event.workflow_run.conclusion == 'success' && gitea.event.workflow_run.event == 'push'
|
|
||||||
runs-on: self-hosted
|
runs-on: self-hosted
|
||||||
needs: docker
|
needs: [test, docker]
|
||||||
env:
|
|
||||||
SERVER_INFRA_ROOT: /home/westfarn/Documents/repos/server-infra
|
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy company_site prod
|
- name: Checkout
|
||||||
run: |
|
uses: actions/checkout@v4
|
||||||
"${SERVER_INFRA_ROOT}/scripts/deploy.sh" \
|
|
||||||
--app company_site \
|
- name: Deploy to live site
|
||||||
--env prod \
|
run: bash scripts/deploy.sh "${{ gitea.workspace }}"
|
||||||
--ref "${{ gitea.event.workflow_run.head_sha }}"
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
name: Unit Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [master]
|
|
||||||
pull_request:
|
|
||||||
branches: [master]
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: self-hosted
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Install uv
|
|
||||||
run: |
|
|
||||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
||||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: uv sync --frozen
|
|
||||||
|
|
||||||
- name: Run unit tests
|
|
||||||
env:
|
|
||||||
DJANGO_ENV: dev
|
|
||||||
DJANGO_SECRET_KEY: test-secret-key
|
|
||||||
run: |
|
|
||||||
cd company_site
|
|
||||||
uv run python manage.py test
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Shared Django settings for all environments."""
|
"""Shared Django settings for all environments."""
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
@@ -23,15 +22,6 @@ def env_list(key: str, default: str = "") -> list[str]:
|
|||||||
value = os.environ.get(key, default)
|
value = os.environ.get(key, default)
|
||||||
if not value:
|
if not value:
|
||||||
return []
|
return []
|
||||||
value = value.strip()
|
|
||||||
# Accept a JSON array (e.g. '["a","b"]') as well as a comma-separated list.
|
|
||||||
if value.startswith("["):
|
|
||||||
try:
|
|
||||||
parsed = json.loads(value)
|
|
||||||
except ValueError:
|
|
||||||
parsed = None
|
|
||||||
if isinstance(parsed, list):
|
|
||||||
return [str(item).strip() for item in parsed if str(item).strip()]
|
|
||||||
return [item.strip() for item in value.split(",") if item.strip()]
|
return [item.strip() for item in value.split(",") if item.strip()]
|
||||||
|
|
||||||
|
|
||||||
@@ -77,7 +67,6 @@ 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 ["*"]
|
||||||
@@ -122,8 +111,6 @@ 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",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -157,7 +144,7 @@ STATIC_URL = "static/"
|
|||||||
STATIC_ROOT = BASE_DIR / "staticfiles"
|
STATIC_ROOT = BASE_DIR / "staticfiles"
|
||||||
STORAGES = {
|
STORAGES = {
|
||||||
"staticfiles": {
|
"staticfiles": {
|
||||||
"BACKEND": "company_site.storage.TolerantManifestStaticFilesStorage",
|
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"""Custom static files storage.
|
|
||||||
|
|
||||||
WhiteNoise's manifest storage post-processes JS/CSS during ``collectstatic`` and
|
|
||||||
strictly resolves every referenced file, including ``sourceMappingURL`` comments
|
|
||||||
in vendored bundles. Some third-party assets reference ``.map`` files that are
|
|
||||||
not shipped, which makes ``collectstatic`` fail hard.
|
|
||||||
|
|
||||||
``TolerantManifestStaticFilesStorage`` leaves such unresolved references
|
|
||||||
untouched instead of raising, so a missing source map can't break the build.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from whitenoise.storage import CompressedManifestStaticFilesStorage
|
|
||||||
|
|
||||||
|
|
||||||
class TolerantManifestStaticFilesStorage(CompressedManifestStaticFilesStorage):
|
|
||||||
# Don't 500 at runtime when a {% static %} reference isn't in the manifest;
|
|
||||||
# fall back to the plain name (mirrors non-manifest storage behaviour).
|
|
||||||
manifest_strict = False
|
|
||||||
|
|
||||||
def _stored_name(self, name, hashed_files):
|
|
||||||
"""Tolerate missing references during collectstatic post-processing."""
|
|
||||||
try:
|
|
||||||
return super()._stored_name(name, hashed_files)
|
|
||||||
except ValueError:
|
|
||||||
return name
|
|
||||||
|
|
||||||
def stored_name(self, name):
|
|
||||||
"""Tolerate missing manifest entries at request time."""
|
|
||||||
try:
|
|
||||||
return super().stored_name(name)
|
|
||||||
except ValueError:
|
|
||||||
return name
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
# 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)
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
# WebMCP Tool Catalog
|
|
||||||
|
|
||||||
WebMCP exposes public marketing actions as named, callable tools via `navigator.modelContext` (or `document.modelContext` in newer builds). Tools register only when `WEBMCP_ENABLED=True` in Django settings.
|
|
||||||
|
|
||||||
## Enablement
|
|
||||||
|
|
||||||
### 1. Django setting
|
|
||||||
|
|
||||||
```python
|
|
||||||
# company_site/settings.py (or environment-specific settings)
|
|
||||||
WEBMCP_ENABLED = True
|
|
||||||
```
|
|
||||||
|
|
||||||
Default is `False` so production stays opt-in until you deliberately enable agent tooling.
|
|
||||||
|
|
||||||
### 2. Chrome experimental flag
|
|
||||||
|
|
||||||
WebMCP requires the experimental web platform features flag:
|
|
||||||
|
|
||||||
1. Open `chrome://flags/#enable-experimental-web-platform-features`
|
|
||||||
2. Set **Enable experimental web platform features** to **Enabled**
|
|
||||||
3. Restart Chrome
|
|
||||||
|
|
||||||
Serve the site over HTTPS (or `localhost`) — WebMCP requires a secure context.
|
|
||||||
|
|
||||||
### 3. Lighthouse audit
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx lighthouse@latest https://aimloperations.com/contact \
|
|
||||||
--only-categories=agentic-browsing \
|
|
||||||
--chrome-flags="--enable-experimental-web-platform-features" \
|
|
||||||
--output=html --output-path=agentic-browsing-contact.html
|
|
||||||
|
|
||||||
npx lighthouse@latest https://aimloperations.com/ \
|
|
||||||
--only-categories=agentic-browsing \
|
|
||||||
--chrome-flags="--enable-experimental-web-platform-features" \
|
|
||||||
--output=html --output-path=agentic-browsing-home.html
|
|
||||||
```
|
|
||||||
|
|
||||||
Use `https://` URLs to avoid redirect warnings. Run with `WEBMCP_ENABLED=True` on the target environment.
|
|
||||||
|
|
||||||
## Registered tools
|
|
||||||
|
|
||||||
| Tool | Pages | readOnly | Description |
|
|
||||||
|------|-------|----------|-------------|
|
|
||||||
| `list_services` | All public pages | Yes | Returns service name, slug, URL, and summary |
|
|
||||||
| `get_page_content` | All public pages | Yes | Look up a page by slug or display name |
|
|
||||||
| `navigate_to_service` | All public pages | Yes | Resolve a service to its canonical URL |
|
|
||||||
| `open_contact_with_subject` | All public pages | Yes | Build a contact URL with `?subject=` pre-filled |
|
|
||||||
| `submit_contact_inquiry` | `/contact` only | No | POST a contact inquiry to the Django contact endpoint |
|
|
||||||
|
|
||||||
### `submit_contact_inquiry`
|
|
||||||
|
|
||||||
**Input schema:**
|
|
||||||
|
|
||||||
| Field | Type | Required |
|
|
||||||
|-------|------|----------|
|
|
||||||
| `name` | string | Yes |
|
|
||||||
| `email` | string | Yes |
|
|
||||||
| `subject` | string | Yes |
|
|
||||||
| `message` | string | No |
|
|
||||||
|
|
||||||
**Output:** JSON with `success: true` and a confirmation message, or `success: false` with an `error` string.
|
|
||||||
|
|
||||||
**reCAPTCHA limitation:** In production (`DEBUG=False`), the contact endpoint requires reCAPTCHA v3. Automated agents cannot complete captcha without a real browser session and the reCAPTCHA widget. For Lighthouse demos, run with `DEBUG=True` or use the declarative HTML form (agent fills fields; user submits manually).
|
|
||||||
|
|
||||||
### Declarative form annotation
|
|
||||||
|
|
||||||
The contact `<form>` declares WebMCP coverage via HTML attributes (always rendered, independent of `WEBMCP_ENABLED`):
|
|
||||||
|
|
||||||
```html
|
|
||||||
<form id="contact-form" toolname="submit_contact_inquiry"
|
|
||||||
tooldescription="Submit a contact inquiry to AI ML Operations">
|
|
||||||
<input name="name" toolparamdescription="Full name of the person submitting the inquiry." required>
|
|
||||||
...
|
|
||||||
</form>
|
|
||||||
<!-- reCAPTCHA renders outside the annotated form and links via form="contact-form" -->
|
|
||||||
```
|
|
||||||
|
|
||||||
The imperative `registerTool` on `/contact` adds structured `execute` behavior with success/error responses when `WEBMCP_ENABLED=True`.
|
|
||||||
|
|
||||||
## Data source
|
|
||||||
|
|
||||||
Service metadata is sourced from `PUBLIC_PAGE_ENTRIES` in `public/seo.py` — the same config that powers `sitemap.xml` and `llms.txt`. Navigation tools always stay in sync with SEO endpoints.
|
|
||||||
|
|
||||||
## Security scope
|
|
||||||
|
|
||||||
- Tools run in the visitor's browser session (no API keys)
|
|
||||||
- Only unauthenticated marketing flows are exposed
|
|
||||||
- Planning, financial, and admin actions are **not** registered
|
|
||||||
|
|
||||||
## Manual verification
|
|
||||||
|
|
||||||
Use [Chrome Labs Awesome WebMCP](https://github.com/chrome-labs/awesome-webmcp) or DevTools console:
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
// Feature-detect (Chrome with experimental flag)
|
|
||||||
'modelContext' in navigator && typeof navigator.modelContext.registerTool === 'function'
|
|
||||||
```
|
|
||||||
|
|
||||||
With a WebMCP-capable browser on `/`, call `list_services` from the agent UI. On `/contact`, call `submit_contact_inquiry` with test data (DEBUG mode).
|
|
||||||
|
|
||||||
## References
|
|
||||||
|
|
||||||
- [WebMCP specification overview](https://specification.website/spec/agent-readiness/webmcp/)
|
|
||||||
- [Google Chrome modern-web-guidance — agentic JavaScript tools](https://github.com/GoogleChrome/modern-web-guidance/blob/main/skills/modern-web-guidance/guides/webmcp/agentic-javascript-tools.md)
|
|
||||||
- [Awesome WebMCP demos](https://github.com/chrome-labs/awesome-webmcp)
|
|
||||||
- Gitea issue #9
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell, UserProfile
|
from .models import Contract, Employee, ChargeNumber, TimeCard, TimeCardCell
|
||||||
|
# Register your models here.
|
||||||
|
|
||||||
class ContractAdmin(admin.ModelAdmin):
|
class ContractAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
@@ -7,10 +8,6 @@ class ContractAdmin(admin.ModelAdmin):
|
|||||||
class EmployeeAdmin(admin.ModelAdmin):
|
class EmployeeAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class UserProfileAdmin(admin.ModelAdmin):
|
|
||||||
list_display = ("user", "user_type")
|
|
||||||
list_filter = ("user_type",)
|
|
||||||
|
|
||||||
class ChargeNumberAdmin(admin.ModelAdmin):
|
class ChargeNumberAdmin(admin.ModelAdmin):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -22,7 +19,6 @@ class TimeCardCellAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
admin.site.register(Contract, ContractAdmin)
|
admin.site.register(Contract, ContractAdmin)
|
||||||
admin.site.register(Employee, EmployeeAdmin)
|
admin.site.register(Employee, EmployeeAdmin)
|
||||||
admin.site.register(UserProfile, UserProfileAdmin)
|
|
||||||
admin.site.register(ChargeNumber, ChargeNumberAdmin)
|
admin.site.register(ChargeNumber, ChargeNumberAdmin)
|
||||||
admin.site.register(TimeCard, TimeCardAdmin)
|
admin.site.register(TimeCard, TimeCardAdmin)
|
||||||
admin.site.register(TimeCardCell, TimeCardCellAdmin)
|
admin.site.register(TimeCardCell, TimeCardCellAdmin)
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import datetime
|
import datetime
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User
|
|
||||||
from django.forms import ModelForm
|
from django.forms import ModelForm
|
||||||
from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel, UserProfile, set_user_type
|
from .models import Employee, Contract, ChargeNumber, TimeCardCell, AddressModel
|
||||||
|
|
||||||
class NewEmployeeForm(ModelForm):
|
class NewEmployeeForm(ModelForm):
|
||||||
first_name = forms.CharField(max_length=30, required=False, label="First Name")
|
first_name = forms.CharField(max_length=30, required=False, label="First Name")
|
||||||
@@ -38,7 +37,6 @@ class NewEmployeeForm(ModelForm):
|
|||||||
employee.workAddress = address
|
employee.workAddress = address
|
||||||
if commit:
|
if commit:
|
||||||
employee.save()
|
employee.save()
|
||||||
set_user_type(employee.user, UserProfile.UserType.EMPLOYEE)
|
|
||||||
return employee
|
return employee
|
||||||
|
|
||||||
class EmployeeForm(ModelForm):
|
class EmployeeForm(ModelForm):
|
||||||
@@ -46,15 +44,6 @@ class EmployeeForm(ModelForm):
|
|||||||
model = Employee
|
model = Employee
|
||||||
fields = ["user", "manager", "primaryAddress", "workAddress", "phoneNumber", "slary"]
|
fields = ["user", "manager", "primaryAddress", "workAddress", "phoneNumber", "slary"]
|
||||||
|
|
||||||
class UserProfileForm(ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = UserProfile
|
|
||||||
fields = ["user_type"]
|
|
||||||
|
|
||||||
class AdminUserTypeForm(forms.Form):
|
|
||||||
user = forms.ModelChoiceField(queryset=User.objects.order_by("username"))
|
|
||||||
user_type = forms.ChoiceField(choices=UserProfile.UserType.choices)
|
|
||||||
|
|
||||||
class ContractForm(ModelForm):
|
class ContractForm(ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Contract
|
model = Contract
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
# Generated manually for issue #14
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
import django.db.models.deletion
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_user_profiles(apps, schema_editor):
|
|
||||||
User = apps.get_model("auth", "User")
|
|
||||||
UserProfile = apps.get_model("financial", "UserProfile")
|
|
||||||
Employee = apps.get_model("financial", "Employee")
|
|
||||||
TimeCardCell = apps.get_model("financial", "TimeCardCell")
|
|
||||||
|
|
||||||
employee_user_ids = set(
|
|
||||||
TimeCardCell.objects.values_list("timeCard__employee__user_id", flat=True)
|
|
||||||
)
|
|
||||||
|
|
||||||
for user in User.objects.all():
|
|
||||||
if user.id in employee_user_ids:
|
|
||||||
UserProfile.objects.update_or_create(
|
|
||||||
user_id=user.id,
|
|
||||||
defaults={"user_type": "employee"},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
UserProfile.objects.update_or_create(
|
|
||||||
user_id=user.id,
|
|
||||||
defaults={"user_type": "client"},
|
|
||||||
)
|
|
||||||
Employee.objects.filter(user_id=user.id).delete()
|
|
||||||
|
|
||||||
|
|
||||||
def reverse_migrate_user_profiles(apps, schema_editor):
|
|
||||||
User = apps.get_model("auth", "User")
|
|
||||||
Employee = apps.get_model("financial", "Employee")
|
|
||||||
|
|
||||||
for user in User.objects.all():
|
|
||||||
Employee.objects.get_or_create(user_id=user.id)
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
("financial", "0014_chargenumber_name"),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="UserProfile",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"user_type",
|
|
||||||
models.CharField(
|
|
||||||
choices=[("employee", "Employee"), ("client", "Client")],
|
|
||||||
default="client",
|
|
||||||
max_length=10,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"user",
|
|
||||||
models.OneToOneField(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="profile",
|
|
||||||
to=settings.AUTH_USER_MODEL,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.RunPython(migrate_user_profiles, reverse_migrate_user_profiles),
|
|
||||||
]
|
|
||||||
@@ -243,29 +243,6 @@ class AddressModel(models.Model):
|
|||||||
state = models.CharField(max_length=2)
|
state = models.CharField(max_length=2)
|
||||||
zip_code = models.CharField(max_length=5)
|
zip_code = models.CharField(max_length=5)
|
||||||
|
|
||||||
|
|
||||||
class UserProfile(models.Model):
|
|
||||||
class UserType(models.TextChoices):
|
|
||||||
EMPLOYEE = "employee", "Employee"
|
|
||||||
CLIENT = "client", "Client"
|
|
||||||
|
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name="profile")
|
|
||||||
user_type = models.CharField(
|
|
||||||
max_length=10,
|
|
||||||
choices=UserType.choices,
|
|
||||||
default=UserType.CLIENT,
|
|
||||||
)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.user} ({self.get_user_type_display()})"
|
|
||||||
|
|
||||||
def is_employee(self):
|
|
||||||
return self.user_type == self.UserType.EMPLOYEE
|
|
||||||
|
|
||||||
def is_client(self):
|
|
||||||
return self.user_type == self.UserType.CLIENT
|
|
||||||
|
|
||||||
|
|
||||||
class Employee(IdMixin, TimeMixin):
|
class Employee(IdMixin, TimeMixin):
|
||||||
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
|
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
|
||||||
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
user = models.OneToOneField(User, on_delete=models.CASCADE)
|
||||||
@@ -302,30 +279,6 @@ class TimeCardCell(IdMixin, TimeMixin):
|
|||||||
charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True)
|
charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True)
|
||||||
|
|
||||||
|
|
||||||
def set_user_type(user, user_type):
|
|
||||||
"""Set user type and sync the Employee record (mutually exclusive types)."""
|
|
||||||
user.__dict__.pop("profile", None)
|
|
||||||
profile, _ = UserProfile.objects.get_or_create(
|
|
||||||
user=user,
|
|
||||||
defaults={"user_type": user_type},
|
|
||||||
)
|
|
||||||
if profile.user_type == user_type:
|
|
||||||
if user_type == UserProfile.UserType.EMPLOYEE:
|
|
||||||
Employee.objects.get_or_create(user=user)
|
|
||||||
return profile
|
|
||||||
|
|
||||||
if user_type == UserProfile.UserType.CLIENT:
|
|
||||||
employee = Employee.objects.filter(user=user).first()
|
|
||||||
if employee and TimeCardCell.objects.filter(timeCard__employee=employee).exists():
|
|
||||||
raise ValueError("Cannot set Client: user has time log entries.")
|
|
||||||
|
|
||||||
profile.user_type = user_type
|
|
||||||
profile.save()
|
|
||||||
user.__dict__.pop("profile", None)
|
|
||||||
|
|
||||||
if user_type == UserProfile.UserType.EMPLOYEE:
|
|
||||||
Employee.objects.get_or_create(user=user)
|
|
||||||
else:
|
|
||||||
Employee.objects.filter(user=user).delete()
|
|
||||||
|
|
||||||
return profile
|
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
from functools import wraps
|
|
||||||
|
|
||||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
|
||||||
from django.core.exceptions import PermissionDenied
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_profile(user):
|
|
||||||
if not user.is_authenticated:
|
|
||||||
return None
|
|
||||||
from .models import UserProfile
|
|
||||||
|
|
||||||
return UserProfile.objects.filter(user_id=user.pk).first()
|
|
||||||
|
|
||||||
|
|
||||||
def is_financial_admin(user):
|
|
||||||
return user.is_active and user.is_superuser
|
|
||||||
|
|
||||||
|
|
||||||
def is_employee_user(user):
|
|
||||||
profile = get_user_profile(user)
|
|
||||||
return bool(profile and profile.is_employee())
|
|
||||||
|
|
||||||
|
|
||||||
def is_client_user(user):
|
|
||||||
profile = get_user_profile(user)
|
|
||||||
return bool(profile and profile.is_client())
|
|
||||||
|
|
||||||
|
|
||||||
def has_financial_access(user):
|
|
||||||
return is_financial_admin(user) or is_employee_user(user) or is_client_user(user)
|
|
||||||
|
|
||||||
|
|
||||||
def can_write_financials(user):
|
|
||||||
return is_financial_admin(user) or is_employee_user(user)
|
|
||||||
|
|
||||||
|
|
||||||
def get_employees():
|
|
||||||
from .models import Employee, UserProfile
|
|
||||||
|
|
||||||
return Employee.objects.filter(
|
|
||||||
user__profile__user_type=UserProfile.UserType.EMPLOYEE
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_user_employee(user):
|
|
||||||
from .models import Employee
|
|
||||||
|
|
||||||
if not is_employee_user(user):
|
|
||||||
return None
|
|
||||||
return Employee.objects.filter(user=user).first()
|
|
||||||
|
|
||||||
|
|
||||||
def financial_admin_required(view_func):
|
|
||||||
return user_passes_test(is_financial_admin)(view_func)
|
|
||||||
|
|
||||||
|
|
||||||
def financial_access_required(view_func):
|
|
||||||
@login_required
|
|
||||||
@wraps(view_func)
|
|
||||||
def _wrapped(request, *args, **kwargs):
|
|
||||||
if has_financial_access(request.user):
|
|
||||||
return view_func(request, *args, **kwargs)
|
|
||||||
raise PermissionDenied
|
|
||||||
|
|
||||||
return _wrapped
|
|
||||||
|
|
||||||
|
|
||||||
def financial_write_required(view_func):
|
|
||||||
@login_required
|
|
||||||
@wraps(view_func)
|
|
||||||
def _wrapped(request, *args, **kwargs):
|
|
||||||
if can_write_financials(request.user):
|
|
||||||
return view_func(request, *args, **kwargs)
|
|
||||||
raise PermissionDenied
|
|
||||||
|
|
||||||
return _wrapped
|
|
||||||
@@ -2,14 +2,10 @@ from django.db.models.signals import post_save
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
from .models import UserProfile
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
def create_profile_for_user(sender, instance, created, **kwargs):
|
def create_employee_for_user(sender, instance, created, **kwargs):
|
||||||
"""Auto-create a UserProfile (default Client) whenever a User is created."""
|
"""Auto-create an Employee record whenever a User is created."""
|
||||||
|
from financial.models import Employee
|
||||||
if created:
|
if created:
|
||||||
UserProfile.objects.get_or_create(
|
Employee.objects.get_or_create(user=instance)
|
||||||
user=instance,
|
|
||||||
defaults={"user_type": UserProfile.UserType.CLIENT},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
<h1 class="section-title">Dashboard</h1>
|
<h1 class="section-title">Dashboard</h1>
|
||||||
|
|
||||||
<div class="card-grid" style="margin-bottom: 3rem;">
|
<div class="card-grid" style="margin-bottom: 3rem;">
|
||||||
{% if is_financial_admin %}
|
|
||||||
<a href="{% url 'contracts' %}" class="card"
|
<a href="{% url 'contracts' %}" class="card"
|
||||||
data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
|
data-tianji-event="financial_nav" data-tianji-event-destination="contracts">
|
||||||
<span class="card-title">View Contracts</span>
|
<span class="card-title">View Contracts</span>
|
||||||
@@ -25,22 +24,15 @@
|
|||||||
<span class="card-title">New Employee</span>
|
<span class="card-title">New Employee</span>
|
||||||
<p class="card-text">Add a new personnel member to your organization.</p>
|
<p class="card-text">Add a new personnel member to your organization.</p>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'manage_users' %}" class="card">
|
|
||||||
<span class="card-title">Manage Users</span>
|
|
||||||
<p class="card-text">Set Employee or Client type for user accounts.</p>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if can_write_financials %}
|
|
||||||
<a href="{% url 'Timekeeping' %}" class="card"
|
<a href="{% url 'Timekeeping' %}" class="card"
|
||||||
data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping">
|
data-tianji-event="financial_nav" data-tianji-event-destination="timekeeping">
|
||||||
<span class="card-title">Log Time</span>
|
<span class="card-title">Log Time</span>
|
||||||
<p class="card-text">Record work hours against specific contracts.</p>
|
<p class="card-text">Record work hours against specific contracts.</p>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
|
||||||
<a href="{% url 'time_logs' %}" class="card"
|
<a href="{% url 'time_logs' %}" class="card"
|
||||||
data-tianji-event="financial_nav" data-tianji-event-destination="time_logs">
|
data-tianji-event="financial_nav" data-tianji-event-destination="time_logs">
|
||||||
<span class="card-title">{% if can_write_financials %}Manage{% else %}View{% endif %} Time Logs</span>
|
<span class="card-title">Manage Time Logs</span>
|
||||||
<p class="card-text">Review{% if can_write_financials %} and edit{% endif %} submitted time entries.</p>
|
<p class="card-text">Review and edit submitted time entries.</p>
|
||||||
</a>
|
</a>
|
||||||
<a href="{% url 'client_reports' %}" class="card">
|
<a href="{% url 'client_reports' %}" class="card">
|
||||||
<span class="card-title">Client Reports</span>
|
<span class="card-title">Client Reports</span>
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
{% load static %}
|
|
||||||
|
|
||||||
{% block title %}Manage Users - AI ML Operations{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<div class="section">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="section-title" style="text-align: left;">Manage Users</h1>
|
|
||||||
|
|
||||||
{% if messages %}
|
|
||||||
{% for message in messages %}
|
|
||||||
<p style="margin-bottom: 1rem; color: {% if message.tags == 'error' %}#ff6666{% else %}var(--primary-color){% endif %};">
|
|
||||||
{{ message }}
|
|
||||||
</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="card" style="max-width: 600px; margin-bottom: 2rem;">
|
|
||||||
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Set User Type</h2>
|
|
||||||
<form method="post">
|
|
||||||
{% csrf_token %}
|
|
||||||
{{ form.as_p }}
|
|
||||||
<button type="submit" class="btn" style="margin-top: 1rem;">Update User Type</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Username</th>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Type</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for u in users %}
|
|
||||||
<tr>
|
|
||||||
<td>{{ u.username }}</td>
|
|
||||||
<td>{{ u.get_full_name|default:"—" }}</td>
|
|
||||||
<td>{{ u.profile.get_user_type_display|default:"Client" }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -7,36 +7,13 @@
|
|||||||
<div class="section">
|
<div class="section">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1 class="section-title" style="text-align: left;">Profile</h1>
|
<h1 class="section-title" style="text-align: left;">Profile</h1>
|
||||||
|
<div class="card" style="max-width: 600px;">
|
||||||
{% if messages %}
|
|
||||||
{% for message in messages %}
|
|
||||||
<p style="margin-bottom: 1rem; color: {% if message.tags == 'error' %}#ff6666{% else %}var(--primary-color){% endif %};">
|
|
||||||
{{ message }}
|
|
||||||
</p>
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div class="card" style="max-width: 600px; margin-bottom: 2rem;">
|
|
||||||
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Account Type</h2>
|
|
||||||
<p style="color: var(--text-muted); margin-bottom: 1rem;">
|
|
||||||
Your account is currently: <strong>{{ profile.get_user_type_display }}</strong>
|
|
||||||
</p>
|
|
||||||
<form method="post">
|
<form method="post">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }}
|
{{ form.as_p }}
|
||||||
{% if can_edit_type %}
|
|
||||||
<button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button>
|
<button type="submit" class="btn" style="margin-top: 1rem;">Save Profile</button>
|
||||||
{% endif %}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if employee_form %}
|
|
||||||
<div class="card" style="max-width: 600px;">
|
|
||||||
<h2 style="font-size: 1.1rem; margin-bottom: 1rem;">Employee Details</h2>
|
|
||||||
{{ employee_form.as_p }}
|
|
||||||
<p style="color: var(--text-muted); font-size: 0.9rem;">Contact an admin to update employee details.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -9,13 +9,11 @@
|
|||||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
|
||||||
<h1 class="section-title" style="margin-bottom: 0;">All Time Logs</h1>
|
<h1 class="section-title" style="margin-bottom: 0;">All Time Logs</h1>
|
||||||
<div>
|
<div>
|
||||||
<a href="{% url 'financial_home' %}" class="btn"
|
<a href="{% url 'financial_index' %}" class="btn"
|
||||||
style="padding: 0.5rem 1.5rem; font-size: 0.9rem; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1);">Back
|
style="padding: 0.5rem 1.5rem; font-size: 0.9rem; margin-right: 1rem; background: var(--surface-color); color: var(--text-color); border: 1px solid rgba(255,255,255,0.1);">Back
|
||||||
to Dashboard</a>
|
to Dashboard</a>
|
||||||
{% if can_edit_logs %}
|
|
||||||
<a href="{% url 'Timekeeping' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Log New
|
<a href="{% url 'Timekeeping' %}" class="btn" style="padding: 0.5rem 1.5rem; font-size: 0.9rem;">Log New
|
||||||
Time</a>
|
Time</a>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,7 +144,6 @@
|
|||||||
<td>{{ log.end_time|default_if_none:"" }}</td>
|
<td>{{ log.end_time|default_if_none:"" }}</td>
|
||||||
<td>{{ log.hour }}</td>
|
<td>{{ log.hour }}</td>
|
||||||
<td>
|
<td>
|
||||||
{% if can_edit_logs %}
|
|
||||||
<a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan"
|
<a href="{% url 'edit_time_log' log.id %}" class="text-cyber-cyan"
|
||||||
style="margin-right: 10px;">Edit</a>
|
style="margin-right: 10px;">Edit</a>
|
||||||
<form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;"
|
<form action="{% url 'delete_time_log' log.id %}" method="POST" style="display:inline;"
|
||||||
@@ -155,9 +152,6 @@
|
|||||||
<button type="submit"
|
<button type="submit"
|
||||||
style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button>
|
style="background:none; border:none; color: #ff4444; cursor:pointer; font-size: 0.95rem; font-family: var(--font-main);">Delete</button>
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
|
||||||
<span style="color: var(--text-muted);">Read only</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
|
|||||||
@@ -1,159 +1,3 @@
|
|||||||
from django.contrib.auth.models import User
|
from django.test import TestCase
|
||||||
from django.test import Client, TestCase
|
|
||||||
from django.urls import reverse
|
|
||||||
|
|
||||||
from financial.models import (
|
# Create your tests here.
|
||||||
AddressModel,
|
|
||||||
ChargeNumber,
|
|
||||||
Contract,
|
|
||||||
Employee,
|
|
||||||
TimeCard,
|
|
||||||
TimeCardCell,
|
|
||||||
UserProfile,
|
|
||||||
set_user_type,
|
|
||||||
)
|
|
||||||
from financial.permissions import get_employees, is_client_user, is_employee_user
|
|
||||||
|
|
||||||
|
|
||||||
class UserProfileSignalTests(TestCase):
|
|
||||||
def test_new_user_gets_client_profile_not_employee(self):
|
|
||||||
user = User.objects.create_user(username="newbie", password="pass")
|
|
||||||
self.assertTrue(UserProfile.objects.filter(user=user, user_type=UserProfile.UserType.CLIENT).exists())
|
|
||||||
self.assertFalse(Employee.objects.filter(user=user).exists())
|
|
||||||
|
|
||||||
|
|
||||||
class SetUserTypeTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.user = User.objects.create_user(username="worker", password="pass")
|
|
||||||
UserProfile.objects.filter(user=self.user).delete()
|
|
||||||
|
|
||||||
def test_set_employee_creates_employee_record(self):
|
|
||||||
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
|
|
||||||
self.assertTrue(Employee.objects.filter(user=self.user).exists())
|
|
||||||
self.assertEqual(self.user.profile.user_type, UserProfile.UserType.EMPLOYEE)
|
|
||||||
|
|
||||||
def test_set_client_removes_employee_without_time_entries(self):
|
|
||||||
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
|
|
||||||
set_user_type(self.user, UserProfile.UserType.CLIENT)
|
|
||||||
self.assertFalse(Employee.objects.filter(user=self.user).exists())
|
|
||||||
self.assertEqual(
|
|
||||||
UserProfile.objects.get(user=self.user).user_type,
|
|
||||||
UserProfile.UserType.CLIENT,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_cannot_set_client_with_time_entries(self):
|
|
||||||
set_user_type(self.user, UserProfile.UserType.EMPLOYEE)
|
|
||||||
employee = Employee.objects.get(user=self.user)
|
|
||||||
contract = Contract.objects.create(
|
|
||||||
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
|
|
||||||
name="Test Contract",
|
|
||||||
)
|
|
||||||
charge = ChargeNumber.objects.create(
|
|
||||||
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
|
|
||||||
contract=contract,
|
|
||||||
)
|
|
||||||
time_card = TimeCard.objects.create(employee=employee)
|
|
||||||
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=2.0)
|
|
||||||
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
set_user_type(self.user, UserProfile.UserType.CLIENT)
|
|
||||||
|
|
||||||
|
|
||||||
class EmployeeFilterTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.employee_user = User.objects.create_user(username="emp", password="pass")
|
|
||||||
self.client_user = User.objects.create_user(username="cli", password="pass")
|
|
||||||
set_user_type(self.employee_user, UserProfile.UserType.EMPLOYEE)
|
|
||||||
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
|
|
||||||
|
|
||||||
def test_get_employees_excludes_clients(self):
|
|
||||||
employees = list(get_employees())
|
|
||||||
self.assertEqual(len(employees), 1)
|
|
||||||
self.assertEqual(employees[0].user, self.employee_user)
|
|
||||||
|
|
||||||
def test_permission_helpers(self):
|
|
||||||
self.assertTrue(is_employee_user(self.employee_user))
|
|
||||||
self.assertFalse(is_employee_user(self.client_user))
|
|
||||||
self.assertTrue(is_client_user(self.client_user))
|
|
||||||
self.assertFalse(is_client_user(self.employee_user))
|
|
||||||
|
|
||||||
|
|
||||||
class FinancialAccessTests(TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.client = Client()
|
|
||||||
self.admin = User.objects.create_superuser(username="admin", password="pass", email="a@test.com")
|
|
||||||
self.employee = User.objects.create_user(username="employee", password="pass")
|
|
||||||
self.client_user = User.objects.create_user(username="client", password="pass")
|
|
||||||
set_user_type(self.employee, UserProfile.UserType.EMPLOYEE)
|
|
||||||
set_user_type(self.client_user, UserProfile.UserType.CLIENT)
|
|
||||||
|
|
||||||
contract = Contract.objects.create(
|
|
||||||
contract_type=Contract.ContractTypeEnum.FIRM_FIX_PRICED,
|
|
||||||
name="C1",
|
|
||||||
budget_hours=100,
|
|
||||||
)
|
|
||||||
charge = ChargeNumber.objects.create(
|
|
||||||
charge_number_type=ChargeNumber.ChargeNumberTypeEnum.LEVEL_OF_EFFORT,
|
|
||||||
contract=contract,
|
|
||||||
)
|
|
||||||
emp_record = Employee.objects.get(user=self.employee)
|
|
||||||
time_card = TimeCard.objects.create(employee=emp_record)
|
|
||||||
TimeCardCell.objects.create(timeCard=time_card, charge_number=charge, hour=4.0)
|
|
||||||
|
|
||||||
def test_client_can_view_reports_readonly(self):
|
|
||||||
self.client.login(username="client", password="pass")
|
|
||||||
response = self.client.get(reverse("client_reports"))
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertContains(response, "C1")
|
|
||||||
|
|
||||||
def test_client_cannot_log_time(self):
|
|
||||||
self.client.login(username="client", password="pass")
|
|
||||||
response = self.client.get(reverse("Timekeeping"))
|
|
||||||
self.assertEqual(response.status_code, 403)
|
|
||||||
|
|
||||||
def test_client_can_view_time_logs_without_edit(self):
|
|
||||||
self.client.login(username="client", password="pass")
|
|
||||||
response = self.client.get(reverse("time_logs"))
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertContains(response, "Read only")
|
|
||||||
self.assertNotContains(response, 'href="/financial/time_logs/')
|
|
||||||
|
|
||||||
def test_employee_can_access_timekeeping(self):
|
|
||||||
self.client.login(username="employee", password="pass")
|
|
||||||
response = self.client.get(reverse("Timekeeping"))
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
def test_client_redirected_from_financial_home_to_reports(self):
|
|
||||||
self.client.login(username="client", password="pass")
|
|
||||||
response = self.client.get(reverse("financial_home"))
|
|
||||||
self.assertRedirects(response, reverse("client_reports"))
|
|
||||||
|
|
||||||
def test_admin_can_manage_users(self):
|
|
||||||
self.client.login(username="admin", password="pass")
|
|
||||||
response = self.client.get(reverse("manage_users"))
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
response = self.client.post(reverse("manage_users"), {
|
|
||||||
"user": self.client_user.id,
|
|
||||||
"user_type": UserProfile.UserType.EMPLOYEE,
|
|
||||||
})
|
|
||||||
self.assertRedirects(response, reverse("manage_users"))
|
|
||||||
self.client_user.refresh_from_db()
|
|
||||||
self.assertEqual(self.client_user.profile.user_type, UserProfile.UserType.EMPLOYEE)
|
|
||||||
|
|
||||||
def test_time_logs_only_lists_employees(self):
|
|
||||||
self.client.login(username="admin", password="pass")
|
|
||||||
response = self.client.get(reverse("time_logs"))
|
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
self.assertContains(response, "employee")
|
|
||||||
employees = response.context["employees"]
|
|
||||||
self.assertEqual(employees.count(), 1)
|
|
||||||
|
|
||||||
def test_new_user_not_in_employee_dropdown(self):
|
|
||||||
extra = User.objects.create_user(username="extra", password="pass")
|
|
||||||
set_user_type(extra, UserProfile.UserType.CLIENT)
|
|
||||||
self.client.login(username="admin", password="pass")
|
|
||||||
response = self.client.get(reverse("time_logs"))
|
|
||||||
employees = response.context["employees"]
|
|
||||||
usernames = [e.user.username for e in employees]
|
|
||||||
self.assertIn("employee", usernames)
|
|
||||||
self.assertNotIn("extra", usernames)
|
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ from django.urls import path
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", views.financial_home, name="financial_home"),
|
path("", views.index, name="financial_index"),
|
||||||
path("dashboard", views.index, name="financial_index"),
|
|
||||||
path("timekeeping", views.timekeeping, name="Timekeeping"),
|
path("timekeeping", views.timekeeping, name="Timekeeping"),
|
||||||
path("time_logs", views.time_logs, name="time_logs"),
|
path("time_logs", views.time_logs, name="time_logs"),
|
||||||
path("time_logs/<int:log_id>/edit", views.edit_time_log, name="edit_time_log"),
|
path("time_logs/<int:log_id>/edit", views.edit_time_log, name="edit_time_log"),
|
||||||
@@ -19,6 +18,5 @@ urlpatterns = [
|
|||||||
#path("contracts/<int:contract_id>/", views.contract_detail, name="contract"),
|
#path("contracts/<int:contract_id>/", views.contract_detail, name="contract"),
|
||||||
path("procurements", views.procurement, name="procurements"),
|
path("procurements", views.procurement, name="procurements"),
|
||||||
path("profile", views.profile, name="profile"),
|
path("profile", views.profile, name="profile"),
|
||||||
path("manage_users", views.manage_users, name="manage_users"),
|
|
||||||
path("client_reports", views.client_reports, name="client_reports"),
|
path("client_reports", views.client_reports, name="client_reports"),
|
||||||
]
|
]
|
||||||
+32
-142
@@ -1,41 +1,23 @@
|
|||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.decorators import user_passes_test
|
||||||
from django.contrib import messages
|
from .forms import EmployeeForm, ContractForm, ChargeNumberForm, TimeLogForm, NewEmployeeForm
|
||||||
|
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.db.models import Sum
|
from django.db.models import Sum
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from .forms import (
|
def is_admin(user):
|
||||||
EmployeeForm,
|
return user.is_active and user.is_superuser
|
||||||
ContractForm,
|
|
||||||
ChargeNumberForm,
|
|
||||||
TimeLogForm,
|
|
||||||
NewEmployeeForm,
|
|
||||||
UserProfileForm,
|
|
||||||
AdminUserTypeForm,
|
|
||||||
)
|
|
||||||
from .models import Contract, ChargeNumber, TimeCard, TimeCardCell, Employee, UserProfile
|
|
||||||
from .permissions import (
|
|
||||||
financial_admin_required,
|
|
||||||
financial_access_required,
|
|
||||||
financial_write_required,
|
|
||||||
get_employees,
|
|
||||||
get_user_employee,
|
|
||||||
is_client_user,
|
|
||||||
is_financial_admin,
|
|
||||||
can_write_financials,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_admin_required
|
|
||||||
def index(request):
|
def index(request):
|
||||||
contracts = Contract.objects.all()
|
contracts = Contract.objects.all()
|
||||||
for c in contracts:
|
for c in contracts:
|
||||||
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
|
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
|
||||||
c.total_logged = total if total else 0.0
|
c.total_logged = total if total else 0.0
|
||||||
|
|
||||||
employees = get_employees()
|
employees = Employee.objects.all()
|
||||||
employee_data = []
|
employee_data = []
|
||||||
for e in employees:
|
for e in employees:
|
||||||
contract_hours = []
|
contract_hours = []
|
||||||
@@ -46,20 +28,10 @@ def index(request):
|
|||||||
|
|
||||||
return render(request, "financial/index.html", {
|
return render(request, "financial/index.html", {
|
||||||
'contracts': contracts,
|
'contracts': contracts,
|
||||||
'employee_data': employee_data,
|
'employee_data': employee_data
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_access_required
|
|
||||||
def financial_home(request):
|
|
||||||
if is_financial_admin(request.user):
|
|
||||||
return redirect('financial_index')
|
|
||||||
if is_client_user(request.user):
|
|
||||||
return redirect('client_reports')
|
|
||||||
return redirect('Timekeeping')
|
|
||||||
|
|
||||||
|
|
||||||
@financial_admin_required
|
|
||||||
def new_employee(request):
|
def new_employee(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = NewEmployeeForm(request.POST)
|
form = NewEmployeeForm(request.POST)
|
||||||
@@ -70,8 +42,7 @@ def new_employee(request):
|
|||||||
form = NewEmployeeForm()
|
form = NewEmployeeForm()
|
||||||
return render(request, 'financial/new_employee.html', {"form": form})
|
return render(request, 'financial/new_employee.html', {"form": form})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_admin_required
|
|
||||||
def contracts(request):
|
def contracts(request):
|
||||||
contracts_list = Contract.objects.all()
|
contracts_list = Contract.objects.all()
|
||||||
today = timezone.now().date()
|
today = timezone.now().date()
|
||||||
@@ -131,8 +102,7 @@ def contracts(request):
|
|||||||
'chart_data_json': json.dumps(chart_data_list)
|
'chart_data_json': json.dumps(chart_data_list)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_admin_required
|
|
||||||
def contract_detail(request, contract_slug):
|
def contract_detail(request, contract_slug):
|
||||||
contract = Contract.objects.filter(slug=contract_slug).first()
|
contract = Contract.objects.filter(slug=contract_slug).first()
|
||||||
|
|
||||||
@@ -152,6 +122,7 @@ def contract_detail(request, contract_slug):
|
|||||||
lines_str = "\n".join(mermaid_gantt_lines)
|
lines_str = "\n".join(mermaid_gantt_lines)
|
||||||
mermaid_gantt = f"gantt\n title {contract.name} Charge Numbers Timeline\n dateFormat YYYY-MM-DD\n section Charge Numbers\n{lines_str}"
|
mermaid_gantt = f"gantt\n title {contract.name} Charge Numbers Timeline\n dateFormat YYYY-MM-DD\n section Charge Numbers\n{lines_str}"
|
||||||
|
|
||||||
|
# --- EVM Data ---
|
||||||
evm = contract.get_evm_data() if contract else {}
|
evm = contract.get_evm_data() if contract else {}
|
||||||
evm_chart_json = json.dumps({
|
evm_chart_json = json.dumps({
|
||||||
'time_series': evm.get('time_series', []),
|
'time_series': evm.get('time_series', []),
|
||||||
@@ -178,8 +149,7 @@ def contract_detail(request, contract_slug):
|
|||||||
'evm_chart_json': evm_chart_json,
|
'evm_chart_json': evm_chart_json,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_admin_required
|
|
||||||
def new_contract(request):
|
def new_contract(request):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = ContractForm(request.POST)
|
form = ContractForm(request.POST)
|
||||||
@@ -190,42 +160,27 @@ def new_contract(request):
|
|||||||
form = ContractForm()
|
form = ContractForm()
|
||||||
return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True})
|
return render(request, 'financial/contract_detail.html', {"form": form, 'is_new': True})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_write_required
|
|
||||||
def timekeeping(request):
|
def timekeeping(request):
|
||||||
employee = get_user_employee(request.user)
|
|
||||||
if not employee and not is_financial_admin(request.user):
|
|
||||||
messages.error(request, "Only employees can log time.")
|
|
||||||
return redirect('financial_home')
|
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = TimeLogForm(request.POST)
|
form = TimeLogForm(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
if is_financial_admin(request.user) and not employee:
|
employee, _ = Employee.objects.get_or_create(user=request.user)
|
||||||
messages.error(request, "Admin must have an Employee profile to log time here.")
|
|
||||||
return redirect('time_logs')
|
|
||||||
|
|
||||||
time_card, _ = TimeCard.objects.get_or_create(
|
time_card, _ = TimeCard.objects.get_or_create(employee=employee, startDate=timezone.now().date(), endDate=timezone.now().date())
|
||||||
employee=employee,
|
|
||||||
startDate=timezone.now().date(),
|
|
||||||
endDate=timezone.now().date(),
|
|
||||||
)
|
|
||||||
cell = form.save(commit=False)
|
cell = form.save(commit=False)
|
||||||
cell.timeCard = time_card
|
cell.timeCard = time_card
|
||||||
cell.save()
|
cell.save()
|
||||||
return redirect('financial_home')
|
return redirect('financial_index')
|
||||||
else:
|
else:
|
||||||
form = TimeLogForm()
|
form = TimeLogForm()
|
||||||
return render(request, 'financial/timekeeping.html', {'form': form})
|
return render(request, 'financial/timekeeping.html', {'form': form})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_access_required
|
|
||||||
def time_logs(request):
|
def time_logs(request):
|
||||||
logs = TimeCardCell.objects.select_related(
|
logs = TimeCardCell.objects.select_related(
|
||||||
'timeCard__employee__user',
|
'timeCard__employee__user',
|
||||||
'charge_number__contract',
|
'charge_number__contract',
|
||||||
).filter(
|
|
||||||
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
|
|
||||||
).order_by('-date', '-created')
|
).order_by('-date', '-created')
|
||||||
|
|
||||||
employee_ids = []
|
employee_ids = []
|
||||||
@@ -276,13 +231,12 @@ def time_logs(request):
|
|||||||
|
|
||||||
return render(request, 'financial/time_logs.html', {
|
return render(request, 'financial/time_logs.html', {
|
||||||
'logs': logs,
|
'logs': logs,
|
||||||
'employees': get_employees().select_related('user').order_by('user__last_name', 'user__first_name'),
|
'employees': Employee.objects.select_related('user').order_by('user__last_name', 'user__first_name'),
|
||||||
'contracts': Contract.objects.order_by('name'),
|
'contracts': Contract.objects.order_by('name'),
|
||||||
'charge_numbers': charge_numbers,
|
'charge_numbers': charge_numbers,
|
||||||
'contract_totals': contract_totals,
|
'contract_totals': contract_totals,
|
||||||
'charge_number_totals': charge_number_totals,
|
'charge_number_totals': charge_number_totals,
|
||||||
'grand_total': grand_total,
|
'grand_total': grand_total,
|
||||||
'can_edit_logs': can_write_financials(request.user),
|
|
||||||
'filters': {
|
'filters': {
|
||||||
'employees': [str(eid) for eid in employee_ids],
|
'employees': [str(eid) for eid in employee_ids],
|
||||||
'month': month or '',
|
'month': month or '',
|
||||||
@@ -291,8 +245,7 @@ def time_logs(request):
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_write_required
|
|
||||||
def edit_time_log(request, log_id):
|
def edit_time_log(request, log_id):
|
||||||
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
||||||
if not log_entry:
|
if not log_entry:
|
||||||
@@ -308,8 +261,7 @@ def edit_time_log(request, log_id):
|
|||||||
|
|
||||||
return render(request, 'financial/edit_time_log.html', {'form': form, 'log': log_entry})
|
return render(request, 'financial/edit_time_log.html', {'form': form, 'log': log_entry})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_write_required
|
|
||||||
def delete_time_log(request, log_id):
|
def delete_time_log(request, log_id):
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
log_entry = TimeCardCell.objects.filter(id=log_id).first()
|
||||||
@@ -317,24 +269,16 @@ def delete_time_log(request, log_id):
|
|||||||
log_entry.delete()
|
log_entry.delete()
|
||||||
return redirect('time_logs')
|
return redirect('time_logs')
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_access_required
|
|
||||||
def client_reports(request):
|
def client_reports(request):
|
||||||
contracts = Contract.objects.all()
|
contracts = Contract.objects.all()
|
||||||
for c in contracts:
|
for c in contracts:
|
||||||
total = TimeCardCell.objects.filter(
|
total = TimeCardCell.objects.filter(charge_number__contract=c).aggregate(Sum('hour'))['hour__sum']
|
||||||
charge_number__contract=c,
|
|
||||||
timeCard__employee__user__profile__user_type=UserProfile.UserType.EMPLOYEE,
|
|
||||||
).aggregate(Sum('hour'))['hour__sum']
|
|
||||||
c.total_logged = total if total else 0.0
|
c.total_logged = total if total else 0.0
|
||||||
c.remaining_budget = c.budget_hours - c.total_logged
|
c.remaining_budget = c.budget_hours - c.total_logged
|
||||||
return render(request, 'financial/reports.html', {
|
return render(request, 'financial/reports.html', {'contracts': contracts})
|
||||||
'contracts': contracts,
|
|
||||||
'read_only': is_client_user(request.user),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_admin_required
|
|
||||||
def update_charge_number(request, charge_number_slug):
|
def update_charge_number(request, charge_number_slug):
|
||||||
charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first()
|
charge_number = ChargeNumber.objects.filter(slug=charge_number_slug).first()
|
||||||
if not charge_number:
|
if not charge_number:
|
||||||
@@ -353,8 +297,7 @@ def update_charge_number(request, charge_number_slug):
|
|||||||
'charge_number': charge_number,
|
'charge_number': charge_number,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_admin_required
|
|
||||||
def new_charge_number(request, contract_slug):
|
def new_charge_number(request, contract_slug):
|
||||||
contract = Contract.objects.filter(slug=contract_slug).first()
|
contract = Contract.objects.filter(slug=contract_slug).first()
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
@@ -366,72 +309,19 @@ def new_charge_number(request, contract_slug):
|
|||||||
return redirect('contract_detail', contract_slug=contract.slug)
|
return redirect('contract_detail', contract_slug=contract.slug)
|
||||||
return redirect('contract_detail', contract_slug=contract_slug)
|
return redirect('contract_detail', contract_slug=contract_slug)
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_admin_required
|
|
||||||
def timeapproval(request):
|
def timeapproval(request):
|
||||||
return render(request, 'financial/not_created.html', {})
|
return render(request, 'financial/not_created.html', {})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_admin_required
|
|
||||||
def chargenumber(request):
|
def chargenumber(request):
|
||||||
return render(request, 'financial/not_created.html', {})
|
return render(request, 'financial/not_created.html', {})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_admin_required
|
|
||||||
def procurement(request):
|
def procurement(request):
|
||||||
return render(request, 'financial/procurement.html', {})
|
return render(request, 'financial/procurement.html', {})
|
||||||
|
|
||||||
|
@user_passes_test(is_admin)
|
||||||
@financial_access_required
|
|
||||||
def profile(request):
|
def profile(request):
|
||||||
profile_obj, _ = UserProfile.objects.get_or_create(user=request.user)
|
form = EmployeeForm()
|
||||||
can_edit_type = is_financial_admin(request.user)
|
return render(request, 'financial/profile.html', {'form': form})
|
||||||
|
|
||||||
if request.method == "POST" and can_edit_type:
|
|
||||||
form = UserProfileForm(request.POST, instance=profile_obj)
|
|
||||||
if form.is_valid():
|
|
||||||
from .models import set_user_type
|
|
||||||
try:
|
|
||||||
set_user_type(request.user, form.cleaned_data['user_type'])
|
|
||||||
messages.success(request, "Profile updated.")
|
|
||||||
except ValueError as exc:
|
|
||||||
messages.error(request, str(exc))
|
|
||||||
return redirect('profile')
|
|
||||||
else:
|
|
||||||
form = UserProfileForm(instance=profile_obj)
|
|
||||||
if not can_edit_type:
|
|
||||||
form.fields['user_type'].disabled = True
|
|
||||||
|
|
||||||
employee = Employee.objects.filter(user=request.user).first()
|
|
||||||
employee_form = None
|
|
||||||
if employee and profile_obj.is_employee():
|
|
||||||
employee_form = EmployeeForm(instance=employee)
|
|
||||||
|
|
||||||
return render(request, 'financial/profile.html', {
|
|
||||||
'form': form,
|
|
||||||
'employee_form': employee_form,
|
|
||||||
'profile': profile_obj,
|
|
||||||
'can_edit_type': can_edit_type,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@financial_admin_required
|
|
||||||
def manage_users(request):
|
|
||||||
if request.method == "POST":
|
|
||||||
form = AdminUserTypeForm(request.POST)
|
|
||||||
if form.is_valid():
|
|
||||||
from .models import set_user_type
|
|
||||||
try:
|
|
||||||
set_user_type(form.cleaned_data['user'], form.cleaned_data['user_type'])
|
|
||||||
messages.success(request, "User type updated.")
|
|
||||||
return redirect('manage_users')
|
|
||||||
except ValueError as exc:
|
|
||||||
messages.error(request, str(exc))
|
|
||||||
else:
|
|
||||||
form = AdminUserTypeForm()
|
|
||||||
|
|
||||||
users = User.objects.select_related('profile').order_by('username')
|
|
||||||
return render(request, 'financial/manage_users.html', {
|
|
||||||
'form': form,
|
|
||||||
'users': users,
|
|
||||||
})
|
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
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):
|
||||||
@@ -21,60 +16,3 @@ 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),
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ class FormWithCaptcha(forms.Form):
|
|||||||
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,
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
"""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")
|
|
||||||
@@ -23,7 +23,6 @@ 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 {
|
||||||
@@ -82,7 +81,6 @@ 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;
|
||||||
@@ -90,11 +88,6 @@ 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;
|
||||||
@@ -102,7 +95,6 @@ nav {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
pointer-events: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-content {
|
.hero-content {
|
||||||
@@ -110,14 +102,11 @@ 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: clamp(2.5rem, 8vw, 4rem);
|
font-size: 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;
|
||||||
@@ -293,41 +282,6 @@ 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;
|
||||||
@@ -422,29 +376,7 @@ nav {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown.dropdown-open .dropdown-content {
|
.dropdown>a::after {
|
||||||
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;
|
||||||
@@ -455,72 +387,12 @@ 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;
|
||||||
@@ -727,21 +599,6 @@ 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;
|
||||||
@@ -780,14 +637,6 @@ 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.
|
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 99 KiB |
@@ -1,7 +1,4 @@
|
|||||||
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;
|
||||||
@@ -112,4 +109,3 @@ function animate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
animate();
|
animate();
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
var NOTICE_KEY = 'aiml_analytics_notice';
|
var CONSENT_KEY = 'aiml_analytics_consent';
|
||||||
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;
|
||||||
@@ -12,26 +10,19 @@
|
|||||||
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 getStorageItem(key) {
|
function getConsent() {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(key);
|
return localStorage.getItem(CONSENT_KEY);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStorageItem(key, value) {
|
function setConsent(value) {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(key, value);
|
localStorage.setItem(CONSENT_KEY, value);
|
||||||
} catch (e) {
|
|
||||||
/* ignore storage errors */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeStorageItem(key) {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* ignore storage errors */
|
/* ignore storage errors */
|
||||||
}
|
}
|
||||||
@@ -80,44 +71,51 @@
|
|||||||
|
|
||||||
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 migrateLegacyConsent() {
|
function acceptTracking() {
|
||||||
var legacyConsent = getStorageItem(LEGACY_CONSENT_KEY);
|
setConsent('accepted');
|
||||||
var legacyDisabled = getStorageItem(LEGACY_DISABLED_KEY);
|
|
||||||
|
|
||||||
if (legacyConsent === 'accepted') {
|
|
||||||
setStorageItem(NOTICE_KEY, 'acknowledged');
|
|
||||||
removeStorageItem(LEGACY_CONSENT_KEY);
|
|
||||||
removeStorageItem(LEGACY_DISABLED_KEY);
|
|
||||||
return 'migrated_acknowledged';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (legacyConsent === 'declined' || legacyDisabled === '1') {
|
|
||||||
removeStorageItem(LEGACY_CONSENT_KEY);
|
|
||||||
removeStorageItem(LEGACY_DISABLED_KEY);
|
|
||||||
return 'migrated_declined';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function acknowledgeNotice() {
|
|
||||||
setStorageItem(NOTICE_KEY, 'acknowledged');
|
|
||||||
hideBanner();
|
hideBanner();
|
||||||
window.aimlTrackWhenReady('notice_acknowledged');
|
try {
|
||||||
|
localStorage.removeItem('tianji.disabled');
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore storage errors */
|
||||||
|
}
|
||||||
|
pendingConsentEvent = true;
|
||||||
|
loadTracker();
|
||||||
|
}
|
||||||
|
|
||||||
|
function declineTracking() {
|
||||||
|
setConsent('declined');
|
||||||
|
hideBanner();
|
||||||
|
try {
|
||||||
|
localStorage.setItem('tianji.disabled', '1');
|
||||||
|
} catch (e) {
|
||||||
|
/* ignore storage errors */
|
||||||
|
}
|
||||||
|
if (window.aimlTrack) {
|
||||||
|
window.aimlTrack('consent_declined');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function bindBannerControls() {
|
function bindBannerControls() {
|
||||||
var acknowledgeBtn = document.getElementById('cookie-consent-acknowledge');
|
var acceptBtn = document.getElementById('cookie-consent-accept');
|
||||||
|
var declineBtn = document.getElementById('cookie-consent-decline');
|
||||||
var manageLinks = document.querySelectorAll('[data-open-cookie-preferences]');
|
var manageLinks = document.querySelectorAll('[data-open-cookie-preferences]');
|
||||||
|
|
||||||
if (acknowledgeBtn) {
|
if (acceptBtn) {
|
||||||
acknowledgeBtn.addEventListener('click', acknowledgeNotice);
|
acceptBtn.addEventListener('click', acceptTracking);
|
||||||
|
}
|
||||||
|
if (declineBtn) {
|
||||||
|
declineBtn.addEventListener('click', declineTracking);
|
||||||
}
|
}
|
||||||
manageLinks.forEach(function (link) {
|
manageLinks.forEach(function (link) {
|
||||||
link.addEventListener('click', function (event) {
|
link.addEventListener('click', function (event) {
|
||||||
@@ -142,14 +140,16 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
function initNotice() {
|
function initConsent() {
|
||||||
bindBannerControls();
|
bindBannerControls();
|
||||||
|
|
||||||
|
var consent = getConsent();
|
||||||
|
if (consent === 'accepted') {
|
||||||
|
hideBanner();
|
||||||
loadTracker();
|
loadTracker();
|
||||||
|
return;
|
||||||
var migration = migrateLegacyConsent();
|
}
|
||||||
var notice = getStorageItem(NOTICE_KEY);
|
if (consent === 'declined') {
|
||||||
|
|
||||||
if (notice === 'acknowledged' || migration === 'migrated_acknowledged') {
|
|
||||||
hideBanner();
|
hideBanner();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -157,5 +157,5 @@
|
|||||||
showBanner();
|
showBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
initNotice();
|
initConsent();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,286 +0,0 @@
|
|||||||
(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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -44,33 +44,23 @@
|
|||||||
{% 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 aria-label="Main navigation">
|
<nav>
|
||||||
<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 type="button" class="mobile-menu-btn" aria-label="Open menu" aria-expanded="false" aria-controls="main-nav-links">☰</button>
|
<button class="mobile-menu-btn" aria-label="Menu">☰</button>
|
||||||
<ul class="nav-links" id="main-nav-links">
|
<ul class="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">
|
||||||
<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 %}"
|
<a href="#"
|
||||||
id="services-menu-button" aria-expanded="false" aria-haspopup="true" aria-controls="services-menu">Services</button>
|
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>
|
||||||
<ul class="dropdown-content" id="services-menu" role="menu" aria-labelledby="services-menu-button">
|
<ul class="dropdown-content">
|
||||||
<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>
|
||||||
@@ -89,28 +79,19 @@
|
|||||||
<li><a href="{% url 'planning:board_view' %}"
|
<li><a href="{% url 'planning:board_view' %}"
|
||||||
class="{% if 'planning' in request.path %}active{% endif %}"
|
class="{% if 'planning' in request.path %}active{% endif %}"
|
||||||
data-tianji-event="nav_planning">Planning</a></li>
|
data-tianji-event="nav_planning">Planning</a></li>
|
||||||
{% if has_financial_access %}
|
<li><a href="{% url 'financial_index' %}"
|
||||||
<li><a href="{% url 'financial_home' %}"
|
|
||||||
class="{% if 'financial' in request.path %}active{% endif %}"
|
class="{% if 'financial' in request.path %}active{% endif %}"
|
||||||
data-tianji-event="nav_financials">Financials</a></li>
|
data-tianji-event="nav_financials">Financials</a></li>
|
||||||
{% endif %}
|
|
||||||
<li class="dropdown" id="user-profile-dropdown">
|
<li class="dropdown" id="user-profile-dropdown">
|
||||||
<button type="button" class="profile-icon-link" aria-label="Account menu for {{ user.get_full_name|default:user.username }}"
|
<a href="#" class="profile-icon-link" title="{{ 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" aria-hidden="true">
|
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<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>
|
||||||
</button>
|
</a>
|
||||||
<ul class="dropdown-content profile-dropdown-content" id="profile-menu" role="menu">
|
<ul class="dropdown-content profile-dropdown-content">
|
||||||
<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;">
|
||||||
@@ -142,7 +123,7 @@
|
|||||||
<a href="{% url 'terms_of_service' %}" data-tianji-event="footer_terms">Terms & Privacy</a>
|
<a href="{% url 'terms_of_service' %}" data-tianji-event="footer_terms">Terms & Privacy</a>
|
||||||
{% if tianji_enabled %}
|
{% if tianji_enabled %}
|
||||||
<span class="footer-separator">|</span>
|
<span class="footer-separator">|</span>
|
||||||
<button type="button" class="footer-link-btn" data-open-cookie-preferences data-tianji-event="footer_cookie_preferences">Cookie Preferences</button>
|
<a href="#" data-open-cookie-preferences data-tianji-event="footer_cookie_preferences">Cookie Preferences</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p class="footer-text footer-text--muted">© 2023 -
|
<p class="footer-text footer-text--muted">© 2023 -
|
||||||
@@ -152,27 +133,23 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{% if tianji_enabled %}
|
{% if tianji_enabled %}
|
||||||
<div id="cookie-consent-banner" class="cookie-consent-banner" hidden role="dialog" aria-modal="true"
|
<div id="cookie-consent-banner" class="cookie-consent-banner" hidden role="dialog" aria-live="polite"
|
||||||
aria-labelledby="cookie-consent-title" aria-describedby="cookie-consent-description">
|
aria-label="Cookie consent">
|
||||||
<div class="cookie-consent-content">
|
<div class="cookie-consent-content">
|
||||||
<h2 id="cookie-consent-title" class="visually-hidden">Analytics notice</h2>
|
<p class="cookie-consent-text">
|
||||||
<p class="cookie-consent-text" id="cookie-consent-description">
|
<span class="cookie-consent-text-full">We use analytics tracking to understand how visitors use our site. Tracking runs only if you accept.
|
||||||
<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 & Privacy Policy</a> for details.</span>
|
See our <a href="{% url 'terms_of_service' %}">Terms of Service & Privacy Policy</a> for details.</span>
|
||||||
<span class="cookie-consent-text-short">We use analytics on this site. <a href="{% url 'terms_of_service' %}">Privacy Policy</a></span>
|
<span class="cookie-consent-text-short">We use analytics if you accept. <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-acknowledge" class="btn">Acknowledge</button>
|
<button type="button" id="cookie-consent-decline" class="btn btn-outline">Decline</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');
|
||||||
@@ -180,41 +157,9 @@
|
|||||||
|
|
||||||
if (mobileBtn && navLinks) {
|
if (mobileBtn && navLinks) {
|
||||||
mobileBtn.addEventListener('click', function () {
|
mobileBtn.addEventListener('click', function () {
|
||||||
const isOpen = navLinks.classList.toggle('active');
|
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 %}
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
{% 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 hero-section--compact">
|
<div class="hero-section" style="height: 40vh; min-height: 300px;">
|
||||||
<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,69 +32,52 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="card-title contact-form-heading" id="contact-form-heading" style="margin-bottom: 2rem;">Send Us a Message</h2>
|
<h4 class="card-title" style="margin-bottom: 2rem;">Send Us a Message</h4>
|
||||||
<form id="contact-form" action="{% url 'contact' %}" method="POST" aria-labelledby="contact-form-heading"
|
<form action="{% url 'contact' %}" method="POST">
|
||||||
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">
|
||||||
<label class="form-label" for="contact-name">Your Name</label>
|
<input type="text" class="form-control" name="name" placeholder="Your Name">
|
||||||
<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">
|
||||||
<label class="form-label" for="contact-email">Your Email</label>
|
<input type="email" class="form-control" name="email" placeholder="Your Email">
|
||||||
<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">
|
||||||
<label class="form-label" for="contact-subject">Subject</label>
|
<input type="text" class="form-control" name="subject" id="contact-subject" placeholder="Subject" value="{{ request.GET.subject|default:'' }}">
|
||||||
<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">
|
||||||
<label class="form-label" for="contact-message">Message</label>
|
<textarea name="message" class="form-control" rows="5" placeholder="What workflow is costing you the most time? What systems does it touch?"></textarea>
|
||||||
<textarea name="message" class="form-control" id="contact-message" rows="5"
|
</div>
|
||||||
placeholder="What workflow is costing you the most time? What systems does it touch?"
|
|
||||||
toolparamdescription="Optional details about the workflow, bottleneck, or systems involved."></textarea>
|
<div class="form-group">
|
||||||
|
{% 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">
|
||||||
<h3 class="card-title">Contact Information</h3>
|
<h5 class="card-title">Contact Information</h5>
|
||||||
<ul class="contact-info-list">
|
<ul style="list-style: none;">
|
||||||
<li class="contact-info-item">
|
<li style="margin-bottom: 1rem; display: flex; align-items: center; gap: 1rem;">
|
||||||
<span class="contact-info-icon" aria-hidden="true">📞</span>
|
<span style="color: var(--primary-color);">📞</span>
|
||||||
<a href="tel:+13304022675">+1 (330) 402-2675</a>
|
<p style="color: var(--text-muted);">+1 (330) 402-2675</p>
|
||||||
</li>
|
</li>
|
||||||
<li class="contact-info-item">
|
<li style="margin-bottom: 1rem; display: flex; align-items: center; gap: 1rem;">
|
||||||
<span class="contact-info-icon" aria-hidden="true">✉️</span>
|
<span style="color: var(--primary-color);">✉️</span>
|
||||||
<a href="mailto:ryan@aimloperations.com">ryan@aimloperations.com</a>
|
<p style="color: var(--text-muted);">ryan@aimloperations.com</p>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -106,16 +106,13 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<div class="form-group col-6" style="padding-left:1rem">
|
<div class="form-group col-6" style="padding-left:1rem">
|
||||||
<label for="legacy-contact-email">Your Email</label>
|
<input type="email" class="form-control" name="email" placeholder="Your Email">
|
||||||
<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">
|
||||||
<label for="legacy-contact-name">Your Name</label>
|
<input type="text" class="form-control" name="name" placeholder="Your Name">
|
||||||
<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">
|
||||||
<label for="legacy-contact-message">Your Message</label>
|
<textarea name="message" type="text" class="form-control" rows="5" placeholder="Your message"></textarea>
|
||||||
<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 %}
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
# 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" aria-hidden="true"></canvas>
|
<canvas id="hero-canvas"></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">
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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 }}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<?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> information about how you interact with the site, as described in Section 4.2.</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>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. Analytics run when you use the site so we can measure usage and improve the experience.
|
tools. Tracking is enabled only after you provide consent through our cookie banner.
|
||||||
</p>
|
</p>
|
||||||
<p>We may collect information such as:</p>
|
<p>When you accept analytics, 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. We show an analytics notice when you first visit the site. You can review it
|
content, and product usability. You may decline analytics at any time using the cookie preferences link
|
||||||
again at any time using the cookie preferences link in the site footer.
|
in the site footer. If you decline, the analytics script will not load.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
We store your acknowledgement locally in your browser so we do not show the notice on every visit.
|
We store your consent choice locally in your browser so we can remember your preference on future visits.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3>4.3 Cookies and Local Storage</h3>
|
<h3>4.3 Cookies and Local Storage</h3>
|
||||||
<p>
|
<p>
|
||||||
In addition to analytics notice storage, our site uses essential cookies and session storage required for
|
In addition to analytics consent 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>Acknowledge our analytics notice through the banner or review it again using the cookie preferences link in the footer</li>
|
<li>Accept or decline analytics tracking through our cookie banner or 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" aria-label="Toggle between monthly and yearly billing">
|
<input type="checkbox" id="pricingToggle" role="switch" aria-labelledby="monthlyLabel yearlyLabel">
|
||||||
<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="{% url 'contact' %}?subject=Web%20Hosting%20Standard%20Plan" class="btn">Get Started</a>
|
<a href="#" 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="{% url 'contact' %}?subject=Web%20Hosting%20Premium%20Plan" class="btn" style="background: var(--secondary-color); color: white;">Save 20%</a>
|
<a href="#" class="btn" style="background: var(--secondary-color); color: white;">Save 20%</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,38 +1,9 @@
|
|||||||
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, EmailMessage
|
from .models import Contact
|
||||||
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(
|
||||||
@@ -55,14 +26,9 @@ 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, 'id="contact-name"')
|
self.assertContains(response, 'name="name"')
|
||||||
self.assertContains(response, 'for="contact-name"')
|
self.assertContains(response, 'name="email"')
|
||||||
self.assertContains(response, 'id="contact-email"')
|
self.assertContains(response, 'name="subject"')
|
||||||
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):
|
||||||
@@ -144,129 +110,18 @@ 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_analytics_notice_when_enabled(self):
|
def test_homepage_includes_consent_banner_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_analytics_notice_when_disabled(self):
|
def test_homepage_omits_consent_banner_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)
|
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from . import seo, views
|
from . import 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"),
|
||||||
|
|||||||
+4
-7
@@ -18,14 +18,11 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
# No required env_file — CI has no .env. Defaults below; for local secrets:
|
env_file:
|
||||||
# docker compose --env-file .env up
|
- .env
|
||||||
environment:
|
environment:
|
||||||
DJANGO_ENV: ${DJANGO_ENV:-dev}
|
DJANGO_ENV: dev
|
||||||
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-dev-only-change-me}
|
DATABASE_URL: postgres://company_site:company_site@db:5432/company_site
|
||||||
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
|
||||||
|
|||||||
Reference in New Issue
Block a user