Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(webui): edit character notes, biography, and custom sections #192

Merged
merged 1 commit into from
Oct 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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