Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a15d9f7c0 | ||
|
|
a9c4c16d4f | ||
|
|
14aa2ac5d6 | ||
|
|
f4771e33d4 |
No files matched your search
@@ -37,7 +37,9 @@
|
|||||||
"Bash(sqlite3 app.db \"SELECT COUNT\\(*\\) FROM augmented_parts WHERE scope_model_id=\\(SELECT id FROM scope_models WHERE name='Viper'\\);\")",
|
"Bash(sqlite3 app.db \"SELECT COUNT\\(*\\) FROM augmented_parts WHERE scope_model_id=\\(SELECT id FROM scope_models WHERE name='Viper'\\);\")",
|
||||||
"Bash(sqlite3 app.db \"PRAGMA wal_checkpoint\\(FULL\\);\")",
|
"Bash(sqlite3 app.db \"PRAGMA wal_checkpoint\\(FULL\\);\")",
|
||||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/login)",
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/login)",
|
||||||
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/admin/imported-parts)"
|
"Bash(curl -s -o /dev/null -w \"%{http_code}\\\\n\" http://127.0.0.1:8000/admin/imported-parts)",
|
||||||
|
"Bash(rg -n \"hx-delete|router.delete\" app/web/admin.py app/templates/admin/*.html)",
|
||||||
|
"Bash(rg -n \"is_superuser\" tests/conftest.py tests/*.py)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,9 @@ AUTO_CREATE_TABLES=true
|
|||||||
COOKIE_NAME=access_token
|
COOKIE_NAME=access_token
|
||||||
# Set true when serving over HTTPS.
|
# Set true when serving over HTTPS.
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
|
# How long a browser login lasts before being bounced back to /login.
|
||||||
|
# Independent of ACCESS_TOKEN_EXPIRE_MINUTES, which governs JSON API tokens.
|
||||||
|
SESSION_TIMEOUT_HOURS=24
|
||||||
|
|
||||||
# --- LDAP (optional; direct-bind) ---
|
# --- LDAP (optional; direct-bind) ---
|
||||||
# When enabled, the login page and /api/auth/ldap-login accept a directory
|
# When enabled, the login page and /api/auth/ldap-login accept a directory
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
VERSION := $(shell git describe --tags --always)
|
VERSION := $(shell git tag | tail -1)
|
||||||
IMAGE_NAME := fitguide
|
IMAGE_NAME := fitguide
|
||||||
|
|
||||||
sync:
|
sync:
|
||||||
|
|||||||
@@ -149,8 +149,9 @@ Every setting is read from an environment variable first, then `.env`
|
|||||||
|----------|---------|-------|
|
|----------|---------|-------|
|
||||||
| `SECRET_KEY` | dev placeholder | **set a strong value in production** |
|
| `SECRET_KEY` | dev placeholder | **set a strong value in production** |
|
||||||
| `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres |
|
| `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres |
|
||||||
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | |
|
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | JSON API bearer token lifetime |
|
||||||
| `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | |
|
| `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | |
|
||||||
|
| `SESSION_TIMEOUT_HOURS` | `24` | browser login (HTMX UI cookie) lifetime |
|
||||||
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
|
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
|
||||||
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
|
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
|
||||||
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
|
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
|
||||||
|
|||||||
+5
-1
@@ -33,9 +33,13 @@ class Settings(BaseSettings):
|
|||||||
database_url: str = "sqlite+aiosqlite:///./app.db"
|
database_url: str = "sqlite+aiosqlite:///./app.db"
|
||||||
auto_create_tables: bool = True
|
auto_create_tables: bool = True
|
||||||
|
|
||||||
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie)
|
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie).
|
||||||
|
# session_timeout_hours governs how long a browser login lasts before the
|
||||||
|
# user is bounced back to /login; it's independent of the JSON API's
|
||||||
|
# short-lived access_token_expire_minutes.
|
||||||
cookie_name: str = "access_token"
|
cookie_name: str = "access_token"
|
||||||
cookie_secure: bool = False
|
cookie_secure: bool = False
|
||||||
|
session_timeout_hours: int = 24
|
||||||
|
|
||||||
# LDAP (direct-bind). When enabled, the login page offers an LDAP option.
|
# LDAP (direct-bind). When enabled, the login page offers an LDAP option.
|
||||||
# At login the user's DN is built from ``ldap_bind_dn_template`` (with
|
# At login the user's DN is built from ``ldap_bind_dn_template`` (with
|
||||||
|
|||||||
@@ -47,10 +47,12 @@ def _create_token(
|
|||||||
return token, payload
|
return token, payload
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(subject: str | int) -> str:
|
def create_access_token(
|
||||||
|
subject: str | int, expires_delta: timedelta | None = None
|
||||||
|
) -> str:
|
||||||
token, _ = _create_token(
|
token, _ = _create_token(
|
||||||
subject,
|
subject,
|
||||||
timedelta(minutes=settings.access_token_expire_minutes),
|
expires_delta or timedelta(minutes=settings.access_token_expire_minutes),
|
||||||
ACCESS_TOKEN_TYPE,
|
ACCESS_TOKEN_TYPE,
|
||||||
)
|
)
|
||||||
return token
|
return token
|
||||||
|
|||||||
+12
-3
@@ -1,5 +1,7 @@
|
|||||||
"""Token issuance / rotation shared by the JSON API and the HTMX UI."""
|
"""Token issuance / rotation shared by the JSON API and the HTMX UI."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -13,9 +15,16 @@ from app.crud import token as token_crud
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]:
|
async def issue_token_pair(
|
||||||
"""Create an access + refresh token and persist the refresh token for revocation."""
|
db: AsyncSession, user: User, access_expires_delta: timedelta | None = None
|
||||||
access = create_access_token(user.id)
|
) -> tuple[str, str]:
|
||||||
|
"""Create an access + refresh token and persist the refresh token for revocation.
|
||||||
|
|
||||||
|
``access_expires_delta`` overrides the access token's default lifetime
|
||||||
|
(``settings.access_token_expire_minutes``) — used by the web login flow to
|
||||||
|
issue a longer-lived session cookie without affecting API bearer tokens.
|
||||||
|
"""
|
||||||
|
access = create_access_token(user.id, access_expires_delta)
|
||||||
refresh, payload = create_refresh_token(user.id)
|
refresh, payload = create_refresh_token(user.id)
|
||||||
await token_crud.store(
|
await token_crud.store(
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -567,6 +567,11 @@ textarea:focus {
|
|||||||
margin-bottom: 0.75rem;
|
margin-bottom: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Bulk actions (e.g. delete-selected above a checkbox table) */
|
||||||
|
.bulk-actions {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
.filter-bar form {
|
.filter-bar form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
<table class="table sortable">
|
<table class="table sortable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
{% if can_write_scopes %}
|
||||||
|
<th data-sort-off><input type="checkbox" id="scopes-select-all" title="Select all"></th>
|
||||||
|
{% endif %}
|
||||||
<th>Scope</th>
|
<th>Scope</th>
|
||||||
<th>Manufacturer</th>
|
<th>Manufacturer</th>
|
||||||
<th>Model</th>
|
<th>Model</th>
|
||||||
@@ -36,7 +39,10 @@
|
|||||||
{% for row in rows %}
|
{% for row in rows %}
|
||||||
{% set scope = row.scope %}
|
{% set scope = row.scope %}
|
||||||
{% set info = row.info %}
|
{% set info = row.info %}
|
||||||
<tr>
|
<tr id="scope-row-{{ scope.id }}">
|
||||||
|
{% if can_write_scopes %}
|
||||||
|
<td><input type="checkbox" name="scope_ids" value="{{ scope.id }}"></td>
|
||||||
|
{% endif %}
|
||||||
<td>
|
<td>
|
||||||
<strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong>
|
<strong><a href="/admin/augmented-parts/{{ scope.id }}">{{ scope.description }}</a></strong>
|
||||||
{% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %}
|
{% if scope.sku %}<br><span class="muted">{{ scope.sku }}</span>{% endif %}
|
||||||
@@ -70,7 +76,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{ 6 + column_types | length }}" class="muted">
|
<td colspan="{{ 6 + column_types | length + (1 if can_write_scopes else 0) }}" class="muted">
|
||||||
No scopes match your search.
|
No scopes match your search.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -44,6 +44,50 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if can_write_scopes %}
|
||||||
|
<div class="bulk-actions">
|
||||||
|
<button id="delete-selected-scopes-btn" type="button" class="btn btn-danger" disabled
|
||||||
|
hx-post="/scopes/bulk-delete"
|
||||||
|
hx-include="[name='scope_ids']:checked, [name='description'], [name='manufacturer_id'], [name='scope_model_id'], [name='sub_part_type_id'], [name='fits_part_type_id']"
|
||||||
|
hx-target="#scopes-table-card"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Delete selected scopes?">Delete selected</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% include "_scopes_table_card.html" %}
|
{% include "_scopes_table_card.html" %}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{% if can_write_scopes %}
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
function updateDeleteButton() {
|
||||||
|
var count = document.querySelectorAll('input[name="scope_ids"]:checked').length;
|
||||||
|
var btn = document.getElementById('delete-selected-scopes-btn');
|
||||||
|
if (!btn) return;
|
||||||
|
btn.textContent = count > 0 ? 'Delete selected (' + count + ')' : 'Delete selected';
|
||||||
|
btn.disabled = count === 0;
|
||||||
|
btn.setAttribute('hx-confirm', 'Delete ' + count + ' selected scope' + (count === 1 ? '' : 's') + '?');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('change', function (e) {
|
||||||
|
if (!e.target) return;
|
||||||
|
|
||||||
|
if (e.target.id === 'scopes-select-all') {
|
||||||
|
document.querySelectorAll('input[name="scope_ids"]').forEach(function (cb) {
|
||||||
|
cb.checked = e.target.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.target.id === 'scopes-select-all' || e.target.name === 'scope_ids') {
|
||||||
|
updateDeleteButton();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-run after every HTMX swap (filter results, post-delete) so the button
|
||||||
|
// state reflects the freshly rendered checkboxes, which start unchecked.
|
||||||
|
document.addEventListener('htmx:afterSwap', updateDeleteButton);
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
+9
-3
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, status
|
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request, status
|
||||||
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
from fastapi.responses import HTMLResponse, RedirectResponse, Response
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -36,7 +38,7 @@ def _set_auth_cookie(response: Response, access_token: str) -> None:
|
|||||||
httponly=True,
|
httponly=True,
|
||||||
secure=settings.cookie_secure,
|
secure=settings.cookie_secure,
|
||||||
samesite="lax",
|
samesite="lax",
|
||||||
max_age=settings.access_token_expire_minutes * 60,
|
max_age=settings.session_timeout_hours * 3600,
|
||||||
path="/",
|
path="/",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,7 +81,9 @@ async def login_submit(
|
|||||||
},
|
},
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
)
|
)
|
||||||
access, _ = await issue_token_pair(db, user)
|
access, _ = await issue_token_pair(
|
||||||
|
db, user, timedelta(hours=settings.session_timeout_hours)
|
||||||
|
)
|
||||||
response = RedirectResponse(
|
response = RedirectResponse(
|
||||||
url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER
|
url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER
|
||||||
)
|
)
|
||||||
@@ -110,7 +114,9 @@ async def login_ldap_submit(
|
|||||||
},
|
},
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
)
|
)
|
||||||
access, _ = await issue_token_pair(db, user)
|
access, _ = await issue_token_pair(
|
||||||
|
db, user, timedelta(hours=settings.session_timeout_hours)
|
||||||
|
)
|
||||||
response = RedirectResponse(
|
response = RedirectResponse(
|
||||||
url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER
|
url=_safe_next(next_url), status_code=status.HTTP_303_SEE_OTHER
|
||||||
)
|
)
|
||||||
|
|||||||
+63
-4
@@ -5,11 +5,13 @@ non-Scope part type listing the accessories that fit each scope. Supports HTMX
|
|||||||
search/filter over scope attributes and required accessory fits.
|
search/filter over scope attributes and required accessory fits.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Form, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
|
from app.crud import audit as audit_crud
|
||||||
|
from app.crud import augmented_part as augmented_part_crud
|
||||||
from app.crud import manufacturer as manufacturer_crud
|
from app.crud import manufacturer as manufacturer_crud
|
||||||
from app.crud import part_type as part_type_crud
|
from app.crud import part_type as part_type_crud
|
||||||
from app.crud import scope_model as scope_model_crud
|
from app.crud import scope_model as scope_model_crud
|
||||||
@@ -26,6 +28,10 @@ def _to_int(value: str) -> int | None:
|
|||||||
return int(value) if value.strip() else None
|
return int(value) if value.strip() else None
|
||||||
|
|
||||||
|
|
||||||
|
def _can_write_scopes(user: User) -> bool:
|
||||||
|
return user.is_superuser or "augmented_parts:write" in user.permissions
|
||||||
|
|
||||||
|
|
||||||
async def _build(db: AsyncSession, filters: dict) -> dict:
|
async def _build(db: AsyncSession, filters: dict) -> dict:
|
||||||
"""Run the guide query for the given raw (string) filter values."""
|
"""Run the guide query for the given raw (string) filter values."""
|
||||||
return await scope_guide_service.build(
|
return await scope_guide_service.build(
|
||||||
@@ -77,7 +83,13 @@ async def scopes_page(
|
|||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request,
|
request,
|
||||||
"scopes.html",
|
"scopes.html",
|
||||||
{"user": user, "filters": filters, **guide, **(await _filter_options(db))},
|
{
|
||||||
|
"user": user,
|
||||||
|
"filters": filters,
|
||||||
|
"can_write_scopes": _can_write_scopes(user),
|
||||||
|
**guide,
|
||||||
|
**(await _filter_options(db)),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -90,7 +102,7 @@ async def scopes_filter(
|
|||||||
sub_part_type_id: str = "",
|
sub_part_type_id: str = "",
|
||||||
fits_part_type_id: str = "",
|
fits_part_type_id: str = "",
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
_: User = Depends(web_require("augmented_parts:read", "fits:read")),
|
user: User = Depends(web_require("augmented_parts:read", "fits:read")),
|
||||||
):
|
):
|
||||||
filters = {
|
filters = {
|
||||||
"description": description,
|
"description": description,
|
||||||
@@ -100,4 +112,51 @@ async def scopes_filter(
|
|||||||
"fits_part_type_id": fits_part_type_id,
|
"fits_part_type_id": fits_part_type_id,
|
||||||
}
|
}
|
||||||
guide = await _build(db, filters)
|
guide = await _build(db, filters)
|
||||||
return templates.TemplateResponse(request, "_scopes_table_card.html", guide)
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"_scopes_table_card.html",
|
||||||
|
{**guide, "can_write_scopes": _can_write_scopes(user)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/scopes/bulk-delete", response_class=HTMLResponse)
|
||||||
|
async def bulk_delete_scopes(
|
||||||
|
request: Request,
|
||||||
|
scope_ids: list[int] = Form([]),
|
||||||
|
description: str = Form(""),
|
||||||
|
manufacturer_id: str = Form(""),
|
||||||
|
scope_model_id: str = Form(""),
|
||||||
|
sub_part_type_id: str = Form(""),
|
||||||
|
fits_part_type_id: str = Form(""),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
actor: User = Depends(web_require("augmented_parts:write")),
|
||||||
|
):
|
||||||
|
for augmented_part_id in scope_ids:
|
||||||
|
ap = await augmented_part_crud.get(db, augmented_part_id)
|
||||||
|
if ap is None:
|
||||||
|
continue
|
||||||
|
label = ap.sku or ap.description
|
||||||
|
await augmented_part_crud.delete(db, ap)
|
||||||
|
await audit_crud.log(
|
||||||
|
db,
|
||||||
|
actor=actor,
|
||||||
|
action="delete",
|
||||||
|
resource_type="AugmentedPart",
|
||||||
|
resource_id=augmented_part_id,
|
||||||
|
description=audit_crud.describe_delete(
|
||||||
|
"AugmentedPart", augmented_part_id, label
|
||||||
|
),
|
||||||
|
)
|
||||||
|
filters = {
|
||||||
|
"description": description,
|
||||||
|
"manufacturer_id": manufacturer_id,
|
||||||
|
"scope_model_id": scope_model_id,
|
||||||
|
"sub_part_type_id": sub_part_type_id,
|
||||||
|
"fits_part_type_id": fits_part_type_id,
|
||||||
|
}
|
||||||
|
guide = await _build(db, filters)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
|
"_scopes_table_card.html",
|
||||||
|
{**guide, "can_write_scopes": _can_write_scopes(actor)},
|
||||||
|
)
|
||||||
@@ -330,3 +330,95 @@ async def test_scopes_page_redirects_when_anonymous(client):
|
|||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
# Deep link is preserved so the user lands back here after logging in.
|
# Deep link is preserved so the user lands back here after logging in.
|
||||||
assert resp.headers["location"] == "/login?next=%2Fscopes"
|
assert resp.headers["location"] == "/login?next=%2Fscopes"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scopes_page_hides_checkboxes_for_read_only_user(
|
||||||
|
client, seeded, make_user, db_sessionmaker
|
||||||
|
):
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
await _seed_guide_data(db)
|
||||||
|
await make_user("reader6@example.com", role_ids=[seeded["roles"]["user"].id])
|
||||||
|
await _web_login(client, "reader6@example.com")
|
||||||
|
resp = await client.get("/scopes")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert 'name="scope_ids"' not in resp.text
|
||||||
|
assert "delete-selected-scopes-btn" not in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_scopes_page_shows_checkboxes_for_write_user(
|
||||||
|
client, seeded, make_user, db_sessionmaker
|
||||||
|
):
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
ids = await _seed_guide_data(db)
|
||||||
|
await make_user("writer@example.com", is_superuser=True)
|
||||||
|
await _web_login(client, "writer@example.com")
|
||||||
|
resp = await client.get("/scopes")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert f'name="scope_ids" value="{ids["s1"].id}"' in resp.text
|
||||||
|
assert "delete-selected-scopes-btn" in resp.text
|
||||||
|
assert 'hx-post="/scopes/bulk-delete"' in resp.text
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_delete_scopes_removes_selected_augmented_parts(
|
||||||
|
client, seeded, make_user, db_sessionmaker
|
||||||
|
):
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
ids = await _seed_guide_data(db)
|
||||||
|
await make_user("writer2@example.com", is_superuser=True)
|
||||||
|
await _web_login(client, "writer2@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/scopes/bulk-delete",
|
||||||
|
data={"scope_ids": [str(ids["s1"].id), str(ids["s2"].id)]},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
from app.crud import augmented_part as augmented_part_crud
|
||||||
|
|
||||||
|
assert await augmented_part_crud.get(db, ids["s1"].id) is None
|
||||||
|
assert await augmented_part_crud.get(db, ids["s2"].id) is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_delete_scopes_leaves_unselected_rows(
|
||||||
|
client, seeded, make_user, db_sessionmaker
|
||||||
|
):
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
ids = await _seed_guide_data(db)
|
||||||
|
await make_user("writer3@example.com", is_superuser=True)
|
||||||
|
await _web_login(client, "writer3@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/scopes/bulk-delete", data={"scope_ids": [str(ids["s1"].id)]}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
from app.crud import augmented_part as augmented_part_crud
|
||||||
|
|
||||||
|
assert await augmented_part_crud.get(db, ids["s1"].id) is None
|
||||||
|
assert await augmented_part_crud.get(db, ids["s2"].id) is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_bulk_delete_scopes_forbidden_without_write_permission(
|
||||||
|
client, seeded, make_user, db_sessionmaker
|
||||||
|
):
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
ids = await _seed_guide_data(db)
|
||||||
|
await make_user("reader7@example.com", role_ids=[seeded["roles"]["user"].id])
|
||||||
|
await _web_login(client, "reader7@example.com")
|
||||||
|
|
||||||
|
resp = await client.post(
|
||||||
|
"/scopes/bulk-delete", data={"scope_ids": [str(ids["s1"].id)]}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
async with db_sessionmaker() as db:
|
||||||
|
from app.crud import augmented_part as augmented_part_crud
|
||||||
|
|
||||||
|
assert await augmented_part_crud.get(db, ids["s1"].id) is not None
|
||||||
Reference in new issue
Block a user