Skip to content

Commit

Permalink
feat(webui): edit character notes, biography, and custom sections
Browse files Browse the repository at this point in the history
  • Loading branch information
natelandau committed Oct 25, 2024
1 parent df1c065 commit 2d17ec9
Show file tree
Hide file tree
Showing 10 changed files with 459 additions and 26 deletions.
2 changes: 2 additions & 0 deletions src/valentina/models/character.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from datetime import datetime
from typing import TYPE_CHECKING, Optional, Union, cast
from uuid import UUID, uuid4

import discord
import inflect
Expand Down Expand Up @@ -60,6 +61,7 @@ class CharacterSheetSection(BaseModel):

title: str
content: str
uuid: UUID = Field(default_factory=uuid4)


class CharacterTrait(Document):
Expand Down
2 changes: 1 addition & 1 deletion src/valentina/models/note.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ async def update_modified_date(self) -> None:
self.date_modified = time_now()

async def display(self, ctx: "ValentinaContext") -> str:
"""Display the note."""
"""Display the note in markdown format."""
creator = discord.utils.get(ctx.bot.users, id=self.created_by)

return f"{self.text.capitalize()} _`@{creator.display_name if creator else 'Unknown'}`_"
19 changes: 15 additions & 4 deletions src/valentina/webui/blueprints/character_edit/blueprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,45 @@

from quart import Blueprint

from .route_info import EditCharacterCustomSection, EditCharacterNote
from .route_profile import EditProfile
from .route_spend_points import SpendPoints, SpendPointsType

blueprint = Blueprint("character_edit", __name__)

blueprint.add_url_rule(
"/character/<string:character_id>/spendfreebie",
"/character/<string:character_id>/spend/freebie",
view_func=SpendPoints.as_view(
SpendPointsType.FREEBIE.value, spend_type=SpendPointsType.FREEBIE
),
methods=["GET", "POST"],
)
blueprint.add_url_rule(
"/character/<string:character_id>/spendexperience",
"/character/<string:character_id>/spend/experience",
view_func=SpendPoints.as_view(
SpendPointsType.EXPERIENCE.value, spend_type=SpendPointsType.EXPERIENCE
),
methods=["GET", "POST"],
)
blueprint.add_url_rule(
"/character/<string:character_id>/spendstoryteller",
"/character/<string:character_id>/spend/storyteller",
view_func=SpendPoints.as_view(
SpendPointsType.STORYTELLER.value, spend_type=SpendPointsType.STORYTELLER
),
methods=["GET", "POST"],
)
blueprint.add_url_rule(
"/character/<string:character_id>/editprofile",
"/character/<string:character_id>/edit/profile",
view_func=EditProfile.as_view("profile"),
methods=["GET", "POST"],
)
blueprint.add_url_rule(
"/character/<string:character_id>/edit/customsection",
view_func=EditCharacterCustomSection.as_view("customsection"),
methods=["GET", "POST", "DELETE"],
)
blueprint.add_url_rule(
"/character/<string:character_id>/edit/note",
view_func=EditCharacterNote.as_view("note"),
methods=["GET", "POST", "DELETE"],
)
257 changes: 257 additions & 0 deletions src/valentina/webui/blueprints/character_edit/route_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
"""Route for editing character info such as notes and custom sheet sections."""

from uuid import UUID

from quart import Response, abort, request, session, url_for
from quart.views import MethodView
from quart_wtf import QuartForm
from wtforms import (
HiddenField,
StringField,
SubmitField,
TextAreaField,
)
from wtforms.validators import DataRequired, Length

from valentina.models import Character, CharacterSheetSection, Note
from valentina.webui import catalog
from valentina.webui.utils import fetch_active_character
from valentina.webui.utils.discord import post_to_audit_log


class CustomSectionForm(QuartForm):
"""Form for a custom section."""

title = StringField(
"Title", validators=[DataRequired(), Length(min=3, message="Must be at least 3 characters")]
)
content = TextAreaField(
"Content",
validators=[DataRequired(), Length(min=3, message="Must be at least 3 characters")],
)

uuid = HiddenField()
submit = SubmitField("Submit")


class CharacterNoteForm(QuartForm):
"""Form for a character note."""

text = TextAreaField("Text", validators=[DataRequired()])
note_id = HiddenField()
submit = SubmitField("Submit")


class EditCharacterCustomSection(MethodView):
"""Edit the character's info."""

async def _build_form(self, character: Character) -> QuartForm:
"""Build the form and populate with existing data if available."""
data = {}

if request.args.get("uuid", None):
uuid = UUID(request.args.get("uuid"))
for section in character.sheet_sections:
if section.uuid == uuid:
data["title"] = str(section.title)
data["content"] = str(section.content)
data["uuid"] = str(section.uuid)
break

return await CustomSectionForm().create_form(data=data)

async def get(self, character_id: str) -> str:
"""Render the form."""
character = await fetch_active_character(character_id, fetch_links=False)

return catalog.render(
"character_edit.CustomSectionForm",
character=character,
form=await self._build_form(character),
join_label=False,
floating_label=True,
post_url=url_for("character_edit.customsection", character_id=character_id),
)

async def post(self, character_id: str) -> Response | str:
"""Process the form."""
character = await fetch_active_character(character_id, fetch_links=False)

form = await self._build_form(character)
if await form.validate_on_submit():
form_data = {
k: v if v else None
for k, v in form.data.items()
if k not in {"submit", "character_id", "csrf_token"}
}

section_title = form_data["title"].strip().title()
section_content = form_data["content"].strip()

updated_existing = False
if form_data.get("uuid"):
uuid = UUID(form_data["uuid"])
for section in character.sheet_sections:
if section.uuid == uuid:
section.title = section_title
section.content = section_content
updated_existing = True
break

if not updated_existing:
character.sheet_sections.append(
CharacterSheetSection(title=section_title, content=section_content)
)

await post_to_audit_log(
msg=f"Character {character.name} section `{section_title}` added",
view=self.__class__.__name__,
)
await character.save()

return Response(
headers={
"HX-Redirect": url_for(
"character_view.view",
character_id=character_id,
success_msg="Custom section updated!",
),
}
)

# If POST request does not validate, return errors
return catalog.render(
"character_edit.CustomSectionForm",
character=character,
form=form,
join_label=False,
floating_label=True,
post_url=url_for("character_edit.customsection", character_id=character_id),
)

async def delete(self, character_id: str) -> Response:
"""Delete the section."""
character = await fetch_active_character(character_id, fetch_links=False)

uuid = request.args.get("uuid", None)
if not uuid:
abort(400)

for section in character.sheet_sections:
if section.uuid == UUID(uuid):
character.sheet_sections.remove(section)
break

await post_to_audit_log(
msg=f"Character {character.name} section `{section.title}` deleted",
view=self.__class__.__name__,
)
await character.save()
return Response(
headers={
"HX-Redirect": url_for(
"character_view.view",
character_id=character_id,
success_msg="Custom section deleted",
),
}
)


class EditCharacterNote(MethodView):
"""Edit the character's note."""

async def _build_form(self) -> QuartForm:
"""Build the form and populate with existing data if available."""
data = {}

if request.args.get("note_id"):
existing_note = await Note.get(request.args.get("note_id"))
if existing_note:
data["text"] = existing_note.text
data["note_id"] = str(existing_note.id)

return await CharacterNoteForm().create_form(data=data)

async def get(self, character_id: str) -> str:
"""Render the form."""
character = await fetch_active_character(character_id, fetch_links=False)
return catalog.render(
"character_edit.CustomSectionForm",
character=character,
form=await self._build_form(),
join_label=False,
floating_label=True,
post_url=url_for("character_edit.note", character_id=character_id),
)

async def post(self, character_id: str) -> Response | str:
"""Process the form."""
character = await fetch_active_character(character_id, fetch_links=True)
form = await self._build_form()

if await form.validate_on_submit():
if not form.data.get("note_id"):
new_note = Note(
text=form.data["text"].strip(),
parent_id=str(character.id),
created_by=session["USER_ID"],
)
await new_note.save()
character.notes.append(new_note)
await character.save()
msg = "Note Added!"
else:
existing_note = await Note.get(form.data["note_id"])
existing_note.text = form.data["text"]
await existing_note.save()
msg = "Note Updated!"

return Response(
headers={
"HX-Redirect": url_for(
"character_view.view",
character_id=character_id,
success_msg=msg,
),
}
)

# If POST request does not validate, return errors
return catalog.render(
"character_edit.CustomSectionForm",
character=character,
form=form,
join_label=False,
floating_label=True,
post_url=url_for("character_edit.note", character_id=character_id),
)

async def delete(self, character_id: str) -> Response:
"""Delete the note."""
character = await fetch_active_character(character_id, fetch_links=True)

note_id = request.args.get("note_id", None)
if not note_id:
abort(400)

existing_note = await Note.get(note_id)
for note in character.notes:
if note == existing_note:
character.notes.remove(note)
break

await existing_note.delete()

await post_to_audit_log(
msg=f"Character {character.name} note `{existing_note.text}` deleted",
view=self.__class__.__name__,
)
await character.save()
return Response(
headers={
"HX-Redirect": url_for(
"character_view.view", character_id=character_id, success_msg="Note deleted"
)
}
)
19 changes: 18 additions & 1 deletion src/valentina/webui/blueprints/character_edit/route_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,15 @@
from quart import Response, abort, url_for
from quart.views import MethodView
from quart_wtf import QuartForm
from wtforms import DateField, HiddenField, SelectField, StringField, SubmitField, ValidationError
from wtforms import (
DateField,
HiddenField,
SelectField,
StringField,
SubmitField,
TextAreaField,
ValidationError,
)
from wtforms.validators import DataRequired, Length, Optional

from valentina.constants import (
Expand Down Expand Up @@ -104,6 +112,14 @@ class ProfileForm(QuartForm):
filters=[str.strip, str.title],
)

bio = TextAreaField(
"Biography",
default="",
validators=[Optional()],
filters=[str.strip],
description="Information about the character. Can have multiple paragraphs and use markdown for formatting.",
)

submit = SubmitField("Submit")
character_id = HiddenField()

Expand Down Expand Up @@ -150,6 +166,7 @@ async def _build_form(self, character: Character) -> QuartForm:
"auspice": character.auspice if character.auspice else "",
"breed": character.breed if character.breed else "",
"clan_name": character.clan_name if character.clan_name else "",
"bio": character.bio if character.bio else "",
}

form = await ProfileForm().create_form(data=data_from_db)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{# def
form:QuartForm,
join_label:bool = False,
floating_label:bool = False,
post_url:str = "",
character:Character,
#}

<div class="d-flex justify-content-center align-items-center ">
<div class="py-4 px-5 mt-5 rounded-3 border shadow-lg w-50"
id="form-container">
<form method="post"
id="profile-form"
hx-post="{{ post_url }}"
novalidate
hx-indicator="#spinner"
hx-target="#sheet-sections"
hx-swap="innerHTML"
encoding="application/x-www-form-urlencoded">
<global.WTFormElements form={{ form }} join_label={{ join_label }} floating_label={{ floating_label }} />
<a href="{{ url_for('character_view.view', character_id=character.id, info_msg="Cancelled") }}"
class="btn btn-outline-secondary ms-2">Cancel</a>
</form>
</div>
</div>
Loading

0 comments on commit 2d17ec9

Please sign in to comment.