Improve data tables, start adding notes

This commit is contained in:
Darryl Nixon 2023-06-02 19:08:43 -07:00
parent 11679795ab
commit 425780f8a4
53 changed files with 552 additions and 224 deletions

View file

@ -6,6 +6,7 @@ repos:
- id: end-of-file-fixer - id: end-of-file-fixer
- id: check-yaml - id: check-yaml
- id: check-added-large-files - id: check-added-large-files
exclude: 'ghostforge/static/js/es6/faker-8.0.2.mjs'
- repo: https://github.com/asottile/reorder_python_imports - repo: https://github.com/asottile/reorder_python_imports
rev: v3.9.0 rev: v3.9.0
hooks: hooks:

View file

@ -28,6 +28,7 @@ from sqlmodel import TIMESTAMP
from ghostforge.db import get_session from ghostforge.db import get_session
from ghostforge.db import User from ghostforge.db import User
from ghostforge.helpers.stringies import age_in_human
from ghostforge.htmljson import HtmlJson from ghostforge.htmljson import HtmlJson
from ghostforge.users import get_current_user from ghostforge.users import get_current_user
@ -104,7 +105,7 @@ async def can_edit_ghost(
@gf.get("/ghosts/{ghost_id}") @gf.get("/ghosts/{ghost_id}")
@hj.html_or_json("ghost.html") @hj.html_or_json("ghosts/ghost.html")
async def read_ghost( async def read_ghost(
ghost_id: int, ghost_id: int,
current_user: Annotated[User, Depends(get_current_user())], current_user: Annotated[User, Depends(get_current_user())],
@ -114,22 +115,25 @@ async def read_ghost(
): ):
if not can_view: if not can_view:
raise HTTPException(status_code=403, detail="You're not authorized to see this ghost") raise HTTPException(status_code=403, detail="You're not authorized to see this ghost")
result = await session.execute( result = (
select(Ghost, User.username.label("owner_username"), User.id.label("owner_guid")) await session.execute(
.join(User, Ghost.owner_id == User.id) select(Ghost, User.username.label("owner_username"), User.id.label("owner_guid"))
.where(Ghost.id == ghost_id) .join(User, Ghost.owner_id == User.id)
) .where(Ghost.id == ghost_id)
ghost = result.first() )
if not ghost: ).first()
if not result:
raise HTTPException(status_code=404, detail="No ghost with that ID") raise HTTPException(status_code=404, detail="No ghost with that ID")
data = { data = {
"ghost": ghost.Ghost, "ghost": result.Ghost,
"owner": ghost.owner_username, "owner": result.owner_username,
"user": current_user, "user": current_user,
"crumbs": [("ghosts", "/ghosts"), (ghost.Ghost.id, False)], "computed": {"age": age_in_human(result.Ghost.birthdate)},
"crumbs": [("ghosts", "/ghosts"), (result.Ghost.id, False)],
} }
request.state.ghostforge = data | getattr(request.state, "ghostforge", {}) request.state.ghostforge = data | getattr(request.state, "ghostforge", {})
return ghost return result
@gf.put("/ghosts/{ghost_id}") @gf.put("/ghosts/{ghost_id}")
@ -179,19 +183,13 @@ async def get_ghosts(
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
request: Request = None, request: Request = None,
): ):
offset = start
limit = offset + length
# Get the column name based on the order_col integer value # Get the column name based on the order_col integer value
ghost_columns = [c.key for c in inspect(Ghost).c] ghost_columns = [c.key for c in inspect(Ghost).c]
order_col_name = ghost_columns[order_col] order_col_name = ghost_columns[order_col]
# Retrieve filtered ghosts from database # Retrieve filtered ghosts from database
query = ( query = select(Ghost, User.username.label("owner_username"), User.id.label("owner_guid")).join(
select(Ghost, User.username.label("owner_username"), User.id.label("owner_guid")) User, Ghost.owner_id == User.id
.offset(offset)
.limit(limit)
.join(User, Ghost.owner_id == User.id)
) )
permission_filter = or_( permission_filter = or_(
Ghost.owner_id == current_user.id, Ghost.owner_id == current_user.id,
@ -214,11 +212,15 @@ async def get_ghosts(
if isinstance(column_attr.type, (AutoString, Text)): if isinstance(column_attr.type, (AutoString, Text)):
conditions.append(column_attr.ilike(f"%{search}%")) conditions.append(column_attr.ilike(f"%{search}%"))
query = query.where(or_(*conditions)) query = query.where(or_(*conditions))
if order_dir == "asc": if order_dir == "asc":
query = query.order_by(asc(getattr(Ghost, order_col_name))) query = query.order_by(asc(getattr(Ghost, order_col_name)))
else: else:
query = query.order_by(desc(getattr(Ghost, order_col_name))) query = query.order_by(desc(getattr(Ghost, order_col_name)))
query = query.offset(start).limit(length)
ghosts = (await session.execute(query)).all() ghosts = (await session.execute(query)).all()
print(len(ghosts))
return { return {
"recordsTotal": total_ghosts, "recordsTotal": total_ghosts,

View file

@ -0,0 +1,21 @@
import datetime
def age_in_human(birthdate: datetime.datetime) -> str:
today = datetime.datetime.now(birthdate.tzinfo)
delta = abs(today - birthdate)
if (years := delta.days // 365) > 0:
res = f"{years} year{'s' if years > 1 else ''}"
elif (months := (delta.days % 365) // 30) > 0:
res = f"{months} month{'s' if months > 1 else ''}"
elif (weeks := ((delta.days % 365) % 30) // 7) > 0:
res = f"{weeks} week{'s' if weeks > 1 else ''}"
elif (days := ((delta.days % 365) % 30) % 7) > 0:
res = f"{days} day{'s' if days > 1 else ''}"
else:
res = "0 days"
res += " early" if birthdate > today else " old"
return res

166
ghostforge/notes.py Normal file
View file

@ -0,0 +1,166 @@
import datetime
from typing import Optional
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from sqlmodel import Field
from sqlmodel import select
from sqlmodel import SQLModel
from ghostforge.db import get_session
from ghostforge.db import User
from ghostforge.ghosts import can_view_ghost
from ghostforge.ghosts import Ghost
from ghostforge.users import get_current_user
class GhostNote(SQLModel, table=True):
id: Optional[int] = Field(primary_key=True)
ghost_id: int = Field(foreign_key=Ghost.id)
user_id: Optional[int] = Field(foreign_key=User.id)
event_time: datetime = Field(default_factory=datetime.utcnow)
content: str
version: int = Field(default=1)
is_deleted: bool = False
class GhostNoteEdit(SQLModel, table=True):
id: Optional[int] = Field(primary_key=True)
note_id: int = Field(foreign_key=GhostNote.id)
user_id: Optional[int] = Field(foreign_key=User.id)
edit_time: datetime = Field(default_factory=datetime.utcnow)
content: str
version: int = Field()
class GhostNoteCreate(GhostNote, table=False):
pass
class GhostNoteUpdate(GhostNote, table=False):
pass
class GhostNoteRead(GhostNote, table=False):
pass
gf = APIRouter()
# Route to read all latest notes for the specified ghost
@gf.get("/ghosts/{ghost_id}/notes/", response_model=list[GhostNoteRead])
async def read_ghost_notes(
ghost_id: int, session: AsyncSession = Depends(get_session), can_view: bool = Depends(can_view_ghost)
):
if not can_view:
raise HTTPException(status_code=403, detail="You're not authorized to see this ghost")
query = (
select(GhostNote)
.join(GhostNoteEdit, isouter=True)
.filter(GhostNote.ghost_id == ghost_id, GhostNote.is_deleted is False)
.order_by(GhostNote.event_time.desc(), GhostNoteEdit.version.desc())
.group_by(GhostNote.id, GhostNoteEdit.note_id)
.options(selectinload(GhostNote.user))
)
notes = (await session.execute(query)).all()
return notes
# Route to create a new note for the specified ghost for the current user
@gf.post("/ghosts/{ghost_id}/notes/new", response_model=GhostNoteRead)
async def create_ghost_note(
ghost_id: int,
note: GhostNoteCreate,
current_user: User = Depends(get_current_user()),
session: AsyncSession = Depends(get_session),
can_view: bool = Depends(can_view_ghost),
):
if not can_view:
raise HTTPException(status_code=403, detail="You're not authorized to see this ghost")
db_note = GhostNote.from_orm(note)
db_note.ghost_id = ghost_id
db_note.user_id = current_user.id
session.add(db_note)
await session.commit()
await session.refresh(db_note)
return db_note
# Route to read the latest version of a note
@gf.get("/ghosts/{ghost_id}/notes/{note_id}", response_model=GhostNoteRead)
async def read_ghost_note(
ghost_id: int, note_id: int, session: AsyncSession = Depends(get_session), can_view: bool = Depends(can_view_ghost)
):
if not can_view:
raise HTTPException(status_code=403, detail="You're not authorized to see this ghost")
query = (
select(GhostNote)
.join(GhostNoteEdit, isouter=True)
.filter(
GhostNote.id == note_id,
GhostNote.ghost_id == ghost_id,
GhostNote.is_deleted is False,
)
.order_by(GhostNote.event_time.desc(), GhostNoteEdit.version.desc())
.group_by(GhostNote.id, GhostNoteEdit.note_id)
.options(selectinload(GhostNote.user))
)
note = (await session.execute(query)).first()
if not note:
raise HTTPException(
status_code=404,
detail="Note not found",
)
return note
# Route to edit a note
@gf.put("/ghosts/{ghost_id}/notes/{note_id}", response_model=GhostNoteRead)
async def update_ghost_note(
ghost_id: int,
note_id: int,
note: GhostNoteUpdate,
current_user: User = Depends(get_current_user()),
session: AsyncSession = Depends(get_session),
can_view: bool = Depends(can_view_ghost),
):
if not can_view:
raise HTTPException(status_code=403, detail="You're not authorized to see this ghost")
query = (
select(GhostNote)
.filter(
GhostNote.id == note_id,
GhostNote.ghost_id == ghost_id,
GhostNote.is_deleted is False,
GhostNote.user_id == current_user.id,
)
.options(selectinload(GhostNote.user))
)
check_note = (await session.execute(query)).first()
if not check_note:
raise HTTPException(
status_code=404,
detail="Note not found",
)
new_edit = GhostNoteEdit(
note_id=check_note.note_id,
user_id=current_user.id,
content=note.content,
version=check_note.version + 1,
)
session.add(new_edit)
await session.commit()
await session.refresh(new_edit)
return new_edit

View file

@ -4,6 +4,7 @@ from fastapi import Depends
from fastapi import FastAPI from fastapi import FastAPI
from fastapi import Request from fastapi import Request
from fastapi import Response from fastapi import Response
from fastapi.responses import FileResponse
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@ -60,6 +61,13 @@ async def on_startup():
await create_db_and_tables() await create_db_and_tables()
@gf.get("/faker.mjs")
async def get_faker():
response = FileResponse("ghostforge/static/js/es6/faker-8.0.2.mjs", media_type="application/javascript")
response.headers["Cache-Control"] = "public, max-age=31536000, immutable"
return response
@gf.get("/", response_class=HTMLResponse) @gf.get("/", response_class=HTMLResponse)
async def home(request: Request, current_user=Depends(get_current_user())): async def home(request: Request, current_user=Depends(get_current_user())):
if current_user: if current_user:

View file

@ -0,0 +1,38 @@
const button = document.getElementById("edit-field-button");
const modal = document.getElementById("edit-field");
const modal_title = document.getElementById("edit_modal_title");
const modal_key = document.getElementById("edit_modal_key");
const modal_input = document.getElementById("edit_modal_input");
const modal_button = document.getElementById("edit_modal_submit")
function editFieldModal(field_id) {
const field = document.getElementById(field_id);
const label = document.getElementById(field_id + "_label");
modal_title.textContent = "Edit " + label.textContent;
modal_key.textContent = "New " + label.textContent;
modal_input.value = field.textContent;
modal_button.onclick = function () {
editField(field, document.getElementById("edit_modal_input").value);
}
button.click();
}
function editField(field, newvalue) {
if (field.textContent == newvalue) {
return;
}
field.textContent = newvalue;
window.location.hash = "#";
}
[].forEach.call(document.querySelectorAll('input[type=checkbox]'), function (checkbox) {
checkbox.checked = checkbox.defaultChecked;
});
const editButtons = document.querySelectorAll("[id^='button_edit_']");
editButtons.forEach(function (button) {
button.addEventListener("click", function () {
const fieldName = button.id.slice("button_edit_".length);
editFieldModal(fieldName);
});
});

File diff suppressed because one or more lines are too long

View file

@ -79,36 +79,22 @@ document.addEventListener("keydown", function (event) {
if (event.key === "Escape" || event.keyCode === 27) { if (event.key === "Escape" || event.keyCode === 27) {
window.location.hash = "#"; window.location.hash = "#";
} }
// Ctrl/Cmd+G for search modal // Ctrl/Cmd+g for search modal
else if (event.key == "g") { else if (event.key == "g") {
const nav_search_button = document.getElementById("nav_search_button"); const nav_search_button = document.getElementById("nav_search_button");
const search_box = document.getElementById("search_string"); if (event.ctrlKey || event.metaKey) {
if (event.ctrlKey) {
event.preventDefault(); event.preventDefault();
nav_search_button.click(); nav_search_button.click();
search_box.focus(); modal_focus("#search_modal");
}
if (event.metaKey) {
event.preventDefault();
nav_search_button.click();
search_box.focus();
} }
} }
// Ctrl/Cmd+N for new ghost modal // Ctrl/Cmd+n for new ghost modal
else if (event.key == "n") { else if (event.key == "n") {
const nav_new_button = document.getElementById("nav_new_button"); const nav_new_button = document.getElementById("nav_new_button");
const new_ghost_box = document.getElementById("new_firstname"); if (event.ctrlKey || event.metaKey) {
if (event.ctrlKey) {
event.preventDefault(); event.preventDefault();
nav_new_button.click(); nav_new_button.click();
new_ghost_box.focus(); modal_focus("#new-ghost-modal");
}
if (event.metaKey) {
event.preventDefault();
nav_new_button.click();
new_ghost_box.focus();
} }
} }
}); });
@ -116,6 +102,5 @@ document.addEventListener("keydown", function (event) {
// Run various onload/unload functions since script is loaded before elements are // Run various onload/unload functions since script is loaded before elements are
window.onload = function () { window.onload = function () {
restoreMenuState(); restoreMenuState();
modal_focus("#search-modal");
}; };
window.onbeforeunload = saveMenuState; window.onbeforeunload = saveMenuState;

View file

@ -0,0 +1,50 @@
const loginError = document.getElementById('auth-error');
$(document).ready(function () {
$('#save_changes').click(function () {
let username = $('#username').text();
let email = $('#email').text();
let password = $('#password').text();
let is_active = $('#edit_active').is(':checked');
let is_verified = $('#edit_verified').is(':checked');
let is_superuser = $('#edit_superuser').is(':checked');
let user_guid = $('#user_guid')[0].value;
let request_body = {
"username": username,
"email": email,
"password": password,
"is_active": is_active,
"is_verified": is_verified,
"is_superuser": is_superuser
};
$.ajax({
url: '/manage/' + user_guid,
type: 'PUT',
contentType: 'application/json',
data: JSON.stringify(request_body),
success: function (result) {
let toast = document.getElementById("error-msg");
if (!toast) {
toast = document.createElement('div');
toast.id = "success-msg";
toast.classList.add("toast", "toast-success");
loginError.appendChild(toast);
}
toast.innerText = "User updated successfully.";
},
error: function (xhr, textStatus, errorThrown) {
let errorMessage = xhr.responseJSON.detail;
let toast = document.getElementById("error-msg");
if (!toast) {
toast = document.createElement('div');
toast.id = "error-msg";
toast.classList.add("toast", "toast-error");
loginError.appendChild(toast);
}
toast.innerText = errorMessage;
}
});
});
});

View file

@ -18,16 +18,16 @@ templates.env.globals["gf_navbar"] = {
"Ghosts": {"Dashboard": "/dashboard", "Browse": "/ghosts"}, "Ghosts": {"Dashboard": "/dashboard", "Browse": "/ghosts"},
"Research": { "Research": {
"Guidebook": "/guidebook", "Guidebook": "/guidebook",
"Cheat Sheet": "/cheatsheet", "Cheat Sheet*": "/cheatsheet",
}, },
"Settings": { "Settings": {
"Your Profile": "/profile", "Your Profile": "/profile",
"Configuration": "/configuration", "Configuration*": "/configuration",
"Integrations": "/integrations", "Integrations*": "/integrations",
"Manage Users": "/manage", "Manage Users": "/manage",
}, },
"Meta": { "Meta": {
"System Logs": "/logs", "System Logs*": "/logs",
"Logout": "/logout", "Logout": "/logout",
}, },
} }

View file

@ -6,9 +6,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='/img/favicon.ico') }}"> <link rel="icon" type="image/x-icon" href="{{ url_for('static', path='/img/favicon.ico') }}">
<title>{% block title %}{{ title }}{% endblock title %}</title> <title>{% block title %}{{ title }}{% endblock title %}</title>
<link href="{{ url_for('static', path='/css/spectre.min.css') }}" rel="stylesheet"> <link href="{{ url_for('static', path='/css/3p/spectre/spectre.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/spectre-exp.min.css') }}" rel="stylesheet"> <link href="{{ url_for('static', path='/css/3p/spectre/spectre-exp.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/spectre-icons.min.css') }}" rel="stylesheet"> <link href="{{ url_for('static', path='/css/3p/spectre/spectre-icons.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/ghostforge.css') }}" rel="stylesheet"> <link href="{{ url_for('static', path='/css/ghostforge.css') }}" rel="stylesheet">
{% block css %}{% endblock css %} {% block css %}{% endblock css %}
<script src="{{ url_for('static', path='/js/ghostforge.js') }}"></script> <script src="{{ url_for('static', path='/js/ghostforge.js') }}"></script>
@ -26,8 +26,8 @@
</div> </div>
</div> </div>
</div> </div>
{% include "modals/modals.html" %} {% block bottom_modals %}{% endblock bottom_modals %}
{% block bottomjs %}{% endblock bottomjs %}
</body> </body>
{% block bottomjs %}{% endblock bottomjs %}
</html> </html>

View file

@ -0,0 +1,30 @@
{% block js %}
<script src="{{ url_for('static', path='/js/3p/jquery-3.7.0.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/dataTables.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/jquery.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/colReorder.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/fixedHeader.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/responsive.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/searchPanes.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/searchBuilder.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/stateRestore.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/dataTables.searchPanes.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/dataTables.colReorder.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/dataTables.stateRestore.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/dataTables.fixedHeader.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/dataTables.responsive.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/3p/datatables/dataTables.searchBuilder.min.js') }}"></script>
{% endblock js %}
{% block css %}
<link href="{{ url_for('static', path='/css/3p/datatables/datatables.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/3p/datatables/dataTables.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/3p/datatables/jquery.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/3p/datatables/colReorder.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/3p/datatables/fixedHeader.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/3p/datatables/responsive.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/3p/datatables/searchPanes.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/3p/datatables/searchBuilder.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/3p/datatables/stateRestore.datatables.min.css') }}" rel="stylesheet">
{% endblock css %}

View file

@ -12,7 +12,7 @@
</span> </span>
</h1> </h1>
<h5>[age] year old American Male</h5> <h5>{{ computed["age"] }} American Male</h5>
</div> </div>
</div> </div>
<div class="columns"> <div class="columns">
@ -90,5 +90,8 @@
</div> </div>
</div> </div>
</div> </div>
<script src="{{ url_for('static', path='/js/ghost.js') }}"></script>
{% endblock content %} {% endblock content %}
{% block bottomjs %}
<script src="{{ url_for('static', path='/js/ghost.js') }}"></script>
{% endblock bottomjs %}

View file

@ -2,34 +2,10 @@
{% block title %}Browse Ghosts{% endblock title %} {% block title %}Browse Ghosts{% endblock title %}
{% block js %}
<script src="{{ url_for('static', path='/js/jquery-3.7.0.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/colReorder.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.searchPanes.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.colReorder.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.stateRestore.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/responsive.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/fixedHeader.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/searchBuilder.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.fixedHeader.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/searchPanes.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.responsive.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/stateRestore.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.searchBuilder.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/jquery.dataTables.min.js') }}"></script>
{% endblock js %}
{% block css %} {% block js %}
<link href="{{ url_for('static', path='/css/datatables.css') }}" rel="stylesheet"> {% include "datatables.html" %}
<link href="{{ url_for('static', path='/css/colReorder.datatables.min.css') }}" rel="stylesheet"> {% endblock js %}
<link href="{{ url_for('static', path='/css/dataTables.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/fixedHeader.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/jquery.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/responsive.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/colReorder.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/stateRestore.datatables.min.css') }}" rel="stylesheet">
{% endblock css %}
{% block content %} {% block content %}
<div class="columns"> <div class="columns">
@ -97,5 +73,13 @@
] ]
}); });
}); });
$(document).ready(function () {
var urlParams = new URLSearchParams(window.location.search);
var searchTerm = urlParams.get('initial');
if (searchTerm) {
$('#ghosts-table').DataTable().search(searchTerm).draw();
}
});
</script> </script>
{% endblock bottomjs %} {% endblock bottomjs %}

View file

@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/x-icon" href="{{ url_for('static', path='/img/favicon.ico') }}"> <link rel="icon" type="image/x-icon" href="{{ url_for('static', path='/img/favicon.ico') }}">
<title>{% block title %}{{ title }}{% endblock title %}</title> <title>{% block title %}{{ title }}{% endblock title %}</title>
<link href="{{ url_for('static', path='/css/spectre.min.css') }}" rel="stylesheet"> <link href="{{ url_for('static', path='/css/3p/spectre/spectre.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/spectre-icons.min.css') }}" rel="stylesheet"> <link href="{{ url_for('static', path='/css/3p/spectre/spectre-icons.min.css') }}" rel="stylesheet">
</head> </head>
<html> <html>

View file

@ -0,0 +1,29 @@
<div class="modal modal-sm" id="edit-field"><a class="modal-overlay" href="#close" aria-label="Close"></a>
<div class="modal-container" role="document">
<div class="modal-header"><a class="btn btn-clear float-right" href="#close" aria-label="Close"></a>
<div class="modal-title h5" id="edit_modal_title"></div>
</div>
<div class="modal-body">
<div class="content">
<form id="edit_modal_form" , method="get" , action="#">
<div class="form-group">
<label class="form-label" for="edit_modal_input">
<h6 id="edit_modal_key"></h6>
</label>
<div id="edit_modal_input_div">
<input class="form-input" id="edit_modal_input" name="edit_modal_new_value" type="text">
</div>
</div>
</form>
</div>
</div>
<div class="modal-footer">
<a class="btn btn-primary" id="edit_modal_submit">Change Value</a>
</div>
</div>
</div>
<div style="display: none;" hidden>
<a class="btn btn-primary btn-sm" href="#edit-field" id="edit-field-button">
</a>
</div>

View file

@ -1,6 +1,6 @@
<div class="modal modal-sm" id="new-ghost-modal"><a class="modal-overlay" href="#" aria-label="Close"></a> <div class="modal modal-sm" id="new-ghost-modal"><a class="modal-overlay" href="#close" aria-label="Close"></a>
<div class="modal-container" role="document"> <div class="modal-container" role="document">
<div class="modal-header"><a class="btn btn-clear float-right" href="#" aria-label="Close"></a> <div class="modal-header"><a class="btn btn-clear float-right" href="#close" aria-label="Close"></a>
<div class="modal-title h5">New Ghost</div> <div class="modal-title h5">New Ghost</div>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@ -20,9 +20,8 @@
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn">Randomize</button> <button class="btn">Random Ghost</button>
<button class="btn btn-primary">Create Ghost</button> <button class="btn btn-primary">Create</button>
<a class="btn btn-link" href="#modals-sizes" aria-label="Close">Close</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,34 +1,23 @@
<div class="modal modal-sm" id="search-modal"><a class="modal-overlay" href="#modals-sizes" aria-label="Close"></a> <div class="modal modal-sm" id="search-modal"><a class="modal-overlay" href="#close" aria-label="Close"></a>
<div class="modal-container" role="document"> <div class="modal-container" role="document">
<div class="modal-header"><a class="btn btn-clear float-right" href="#modals-sizes" aria-label="Close"></a> <div class="modal-header"><a class="btn btn-clear float-right" href="#close" aria-label="Close"></a>
<div class="modal-title h5">Search Ghosts</div> <div class="modal-title h5">Search Ghosts</div>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="content"> <div class="content">
<form> <form method="get" , action="/ghosts">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="search_string"> <label class="form-label" for="search_string">
<h6>Search Term</h6> <h6>Search Term</h6>
</label> </label>
<input class="form-input" id="search_string" type="text" placeholder="Type a string here"> <input class="form-input" id="search_string" name="initial" type="text"
</div> placeholder="Type a string here">
<div class="form-group">
<label class="form-label">
<h6>Scope</h6>
</label>
<label class="form-radio">
<input type="radio" name="scope"><i class="form-icon"></i> All text fields
</label>
<label class="form-radio">
<input type="radio" name="scope" checked=""><i class="form-icon"></i> Name fields only
</label>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-primary">Search</button> <button class="btn btn-primary">Search</button>
<a class="btn btn-link" href="#modals-sizes" aria-label="Close">Close</a>
</div> </div>
</div> </div>
</div> </div>

View file

@ -24,7 +24,6 @@
</div> </div>
</details> </details>
{% endfor %} {% endfor %}
<!-- ETC -->
</div> </div>
</div> </div>
<div align="right"> <div align="right">
@ -38,3 +37,13 @@
</div> </div>
<a class="off-canvas-overlay" href="#close"></a> <a class="off-canvas-overlay" href="#close"></a>
{% block bottom_modals %}
{% include "modals/side_modals.html" %}
{% endblock bottom_modals %}
{% block bottomjs %}
<script type="module">
import { faker } from "/faker.mjs";
console.log(faker.person.firstName());
</script>
{% endblock bottomjs %}

View file

@ -1,14 +1,14 @@
<div class="text-center"> <div class="text-center">
<div style="display: inline-block;">
<a class="btn btn-primary btn-sm" href="#search-modal" id="nav_search_button">
<i class="icon icon-search"></i>
⌘-g
</a>
</div>
<div style="display: inline-block;"> <div style="display: inline-block;">
<a class="btn btn-primary btn-sm" href="#new-ghost-modal" id="nav_new_button"> <a class="btn btn-primary btn-sm" href="#new-ghost-modal" id="nav_new_button">
<i class="icon icon-plus"></i> <i class="icon icon-plus"></i>
⌘-n ⌘-n
</a> </a>
</div> </div>
<div style="display: inline-block;">
<a class="btn btn-primary btn-sm" href="#search-modal" id="nav_search_button">
<i class="icon icon-search"></i>
⌘-g
</a>
</div>
</div> </div>

View file

@ -1,74 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ target_user.username }}{% endblock title %}
{% block content %}
<div class="columns">
<div class="column col-12">
<h1>Manage User</h1>
</div>
</div>
<div class="columns">
<div class="column col-4"></div>
<div class="column col-4">
<div class="panel">
<div class="panel-header text-center">
<figure class="avatar avatar-lg"><img src="{{ url_for('static', path='/img/default-avatar.png') }}"
alt="Avatar"></figure>
<div class="panel-title h5 mt-10">{{ target_user.username }}</div>
<div class="panel-subtitle">
{% if target_user.is_superuser %}
<kbd>Administrator</kbd>
{% else %}
Normal User
{% endif %}
{% if not target_user.is_active %}
<s>Deactivated</s>
{% endif %}
</div>
</div>
<nav class="panel-nav">
<ul class="tab tab-block">
<li class="tab-item active">Profile</li>
</ul>
</nav>
<div class="panel-body">
<div class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold">Username</div>
<div class="tile-subtitle">{{ target_user.username }}</div>
</div>
<div class="tile-action">
<button class="btn btn-link btn-action btn-lg tooltip tooltip-left"
data-tooltip="Change Username" id="edit_username"><i class="icon icon-edit"></i></button>
</div>
</div>
<div class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold">E-mail</div>
<div class="tile-subtitle">{{ target_user.email }}</div>
</div>
<div class="tile-action">
<button class="btn btn-link btn-action btn-lg tooltip tooltip-left" data-tooltip="Change E-mail"
id="edit_email"><i class="icon icon-edit"></i></button>
</div>
</div>
<div class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold">New Password</div>
<div class="tile-subtitle">blocked</div>
</div>
<div class="tile-action">
<button class="btn btn-link btn-action btn-lg tooltip tooltip-left"
data-tooltip="Change Password" id="edit_password"><i class="icon icon-edit"></i></button>
</div>
</div>
</div>
<div class="panel-footer">
<button class="btn btn-primary btn-block">Save</button>
</div>
</div>
</div>
<div class="column col-4"></div>
</div>
{% endblock content %}

View file

@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}{{ target_user.username }}{% endblock title %}
{% block js %}
<script src="{{ url_for('static', path='/js/3p/jquery-3.7.0.min.js') }}"></script>
{% endblock js %}
{% block content %}
<div class="columns">
<div class="column col-12">
<h1>Manage User</h1>
</div>
</div>
<div class="columns">
<div class="column col-3"></div>
<div class="column col-6">
<div id="auth-error"></div>
<div class="panel">
<div class="panel-header text-center">
<figure class="avatar avatar-lg"><img src="{{ url_for('static', path='/img/default-avatar.png') }}"
alt="Avatar"></figure>
<div class="panel-title h5 mt-10">{{ target_user.username }}</div>
<div class="panel-subtitle">
{% if target_user.is_superuser %}
<kbd>Administrator</kbd>
{% else %}
Normal User
{% endif %}
{% if not target_user.is_active %}
<s>Deactivated</s>
{% endif %}
</div>
</div>
<nav class="panel-nav">
<ul class="tab tab-block">
<li class="tab-item active">Profile</li>
</ul>
</nav>
<div class="panel-body">
<div class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold" id="username_label">Username</div>
<div class="tile-subtitle" id="username">{{ target_user.username }}</div>
<input type="hidden" id="user_guid" value="{{ target_user.id }}" />
</div>
<div class="tile-action">
<a class="btn btn-link btn-action btn-lg tooltip tooltip-left" data-tooltip="Change Username"
id="button_edit_username"><i class="icon icon-edit"></i></a>
</div>
</div>
<div class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold" id="email_label">E-mail</div>
<div class="tile-subtitle" id="email">{{ target_user.email }}</div>
</div>
<div class="tile-action">
<button class="btn btn-link btn-action btn-lg tooltip tooltip-left" data-tooltip="Change E-mail"
id="button_edit_email"><i class="icon icon-edit"></i></button>
</div>
</div>
<div class="tile tile-centered">
<div class="tile-content">
<div class="tile-title text-bold" id="password_label">Password</div>
<div class="tile-subtitle" id="password">***</div>
</div>
<div class="tile-action">
<button class="btn btn-link btn-action btn-lg tooltip tooltip-left"
data-tooltip="Change Password" id="button_edit_password"><i
class="icon icon-edit"></i></button>
</div>
</div>
<div class="form-group">
<label class="form-checkbox form-inline">
<input type="checkbox" id="edit_active" {% if target_user.is_active %}checked{% endif %}><i
class="form-icon"></i> Active
</label>
<label class="form-checkbox form-inline">
<input type="checkbox" id="edit_superuser" {% if target_user.is_verified %}checked{% endif %}><i
class="form-icon"></i> Verified
</label>
<label class="form-checkbox form-inline">
<input type="checkbox" id="edit_superuser" {% if target_user.is_superuser %}checked{% endif
%}><i class="form-icon"></i> Administrator
</label>
</div>
</div>
<div class="panel-footer">
<button class="btn btn-primary btn-block" id="save_changes">Save</button>
</div>
</div>
</div>
<div class="column col-3"></div>
</div>
{% endblock content %}
{% block bottom_modals %}
{% include "modals/edit_field.html" %}
{% endblock bottom_modals %}
{% block bottomjs %}
<script src="{{ url_for('static', path='/js/edit.js') }}"></script>
<script src="{{ url_for('static', path='/js/user.js') }}"></script>
{% endblock bottomjs %}

View file

@ -3,34 +3,9 @@
{% block title %}Browse Ghosts{% endblock title %} {% block title %}Browse Ghosts{% endblock title %}
{% block js %} {% block js %}
<script src="{{ url_for('static', path='/js/jquery-3.7.0.min.js') }}"></script> {% include "datatables.html" %}
<script src="{{ url_for('static', path='/js/colReorder.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.searchPanes.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.colReorder.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.stateRestore.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/responsive.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/fixedHeader.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/searchBuilder.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.fixedHeader.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/searchPanes.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.responsive.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/stateRestore.dataTables.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/dataTables.searchBuilder.min.js') }}"></script>
<script src="{{ url_for('static', path='/js/jquery.dataTables.min.js') }}"></script>
{% endblock js %} {% endblock js %}
{% block css %}
<link href="{{ url_for('static', path='/css/datatables.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/colReorder.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/dataTables.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/fixedHeader.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/jquery.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/responsive.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/colReorder.datatables.min.css') }}" rel="stylesheet">
<link href="{{ url_for('static', path='/css/stateRestore.datatables.min.css') }}" rel="stylesheet">
{% endblock css %}
{% block content %} {% block content %}
<div class="columns"> <div class="columns">
<div class="column col-12"> <div class="column col-12">

View file

@ -64,7 +64,8 @@ class UserCreate(schemas.BaseUserCreate):
class UserUpdate(schemas.BaseUserUpdate): class UserUpdate(schemas.BaseUserUpdate):
pass username: str
email: str
class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]): class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
@ -105,7 +106,7 @@ def get_current_user(active: bool = True, optional: bool = False) -> User:
@gf.get("/manage", response_model=List[UserRead]) @gf.get("/manage", response_model=List[UserRead])
@hj.html_or_json("users.html") @hj.html_or_json("users/users.html")
async def read_users( async def read_users(
current_user: Annotated[User, Depends(get_current_user())], current_user: Annotated[User, Depends(get_current_user())],
offset: int = 0, offset: int = 0,
@ -146,7 +147,7 @@ async def create_user(
@gf.get("/manage/{user_guid}", response_model=UserRead) @gf.get("/manage/{user_guid}", response_model=UserRead)
@hj.html_or_json("user.html") @hj.html_or_json("users/user.html")
async def get_user( async def get_user(
user_guid: str, user_guid: str,
current_user: Annotated[User, Depends(get_current_user())], current_user: Annotated[User, Depends(get_current_user())],
@ -170,7 +171,7 @@ async def get_user(
@gf.put("/manage/{user_guid}", response_model=UserRead) @gf.put("/manage/{user_guid}", response_model=UserRead)
async def update_user( async def update_user(
user_guid: str, user_guid: uuid.UUID,
user_update: UserUpdate, user_update: UserUpdate,
current_user: Annotated[User, Depends(get_current_user())], current_user: Annotated[User, Depends(get_current_user())],
user_manager: UserManager = Depends(get_user_manager), user_manager: UserManager = Depends(get_user_manager),
@ -178,20 +179,12 @@ async def update_user(
): ):
if not current_user.is_superuser and current_user.id != user_guid: if not current_user.is_superuser and current_user.id != user_guid:
raise HTTPException(status_code=403, detail="No permission to edit this user") raise HTTPException(status_code=403, detail="No permission to edit this user")
db_user = await user_manager.get(user_guid, session) db_user = await user_manager.get(user_guid)
if not db_user: if not db_user:
raise HTTPException(status_code=404, detail="User not found") raise HTTPException(status_code=404, detail="User not found")
update_data = user_update.dict(exclude_unset=True) user_update = UserUpdate(**user_update.dict(exclude_unset=True))
if "is_superuser" in update_data and not current_user.is_superuser: return await user_manager.update(user_update, db_user)
del update_data["is_superuser"]
if "password" in update_data:
update_data["hashed_password"] = await user_manager.hash_password(update_data["password"])
del update_data["password"]
await user_manager.update(db_user, session, **update_data)
await session.refresh(db_user)
return db_user
@gf.get("/profile", response_class=HTMLResponse) @gf.get("/profile", response_class=HTMLResponse)

View file

@ -24,6 +24,7 @@ dependencies = [
"fastapi-users-db-sqlmodel==0.3.0", "fastapi-users-db-sqlmodel==0.3.0",
"sqlmodel==0.0.8", "sqlmodel==0.0.8",
"markdown==3.4.3", "markdown==3.4.3",
"faker==18.9.0",
] ]
[project.scripts] [project.scripts]