diff --git a/src/valentina/models/character.py b/src/valentina/models/character.py index 0174be2..f61fb36 100644 --- a/src/valentina/models/character.py +++ b/src/valentina/models/character.py @@ -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 @@ -60,6 +61,7 @@ class CharacterSheetSection(BaseModel): title: str content: str + uuid: UUID = Field(default_factory=uuid4) class CharacterTrait(Document): diff --git a/src/valentina/models/note.py b/src/valentina/models/note.py index 0b7712b..37a1f45 100644 --- a/src/valentina/models/note.py +++ b/src/valentina/models/note.py @@ -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'}`_" diff --git a/src/valentina/webui/blueprints/character_edit/blueprint.py b/src/valentina/webui/blueprints/character_edit/blueprint.py index c675d8f..2163b33 100644 --- a/src/valentina/webui/blueprints/character_edit/blueprint.py +++ b/src/valentina/webui/blueprints/character_edit/blueprint.py @@ -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//spendfreebie", + "/character//spend/freebie", view_func=SpendPoints.as_view( SpendPointsType.FREEBIE.value, spend_type=SpendPointsType.FREEBIE ), methods=["GET", "POST"], ) blueprint.add_url_rule( - "/character//spendexperience", + "/character//spend/experience", view_func=SpendPoints.as_view( SpendPointsType.EXPERIENCE.value, spend_type=SpendPointsType.EXPERIENCE ), methods=["GET", "POST"], ) blueprint.add_url_rule( - "/character//spendstoryteller", + "/character//spend/storyteller", view_func=SpendPoints.as_view( SpendPointsType.STORYTELLER.value, spend_type=SpendPointsType.STORYTELLER ), methods=["GET", "POST"], ) blueprint.add_url_rule( - "/character//editprofile", + "/character//edit/profile", view_func=EditProfile.as_view("profile"), methods=["GET", "POST"], ) +blueprint.add_url_rule( + "/character//edit/customsection", + view_func=EditCharacterCustomSection.as_view("customsection"), + methods=["GET", "POST", "DELETE"], +) +blueprint.add_url_rule( + "/character//edit/note", + view_func=EditCharacterNote.as_view("note"), + methods=["GET", "POST", "DELETE"], +) diff --git a/src/valentina/webui/blueprints/character_edit/route_info.py b/src/valentina/webui/blueprints/character_edit/route_info.py new file mode 100644 index 0000000..3c2d034 --- /dev/null +++ b/src/valentina/webui/blueprints/character_edit/route_info.py @@ -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" + ) + } + ) diff --git a/src/valentina/webui/blueprints/character_edit/route_profile.py b/src/valentina/webui/blueprints/character_edit/route_profile.py index 07d07e0..9a44799 100644 --- a/src/valentina/webui/blueprints/character_edit/route_profile.py +++ b/src/valentina/webui/blueprints/character_edit/route_profile.py @@ -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 ( @@ -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() @@ -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) diff --git a/src/valentina/webui/blueprints/character_edit/templates/character_edit/CustomSectionForm.jinja b/src/valentina/webui/blueprints/character_edit/templates/character_edit/CustomSectionForm.jinja new file mode 100644 index 0000000..7705a34 --- /dev/null +++ b/src/valentina/webui/blueprints/character_edit/templates/character_edit/CustomSectionForm.jinja @@ -0,0 +1,25 @@ +{# def + form:QuartForm, + join_label:bool = False, + floating_label:bool = False, + post_url:str = "", + character:Character, +#} + +
+
+
+ + Cancel + +
+
diff --git a/src/valentina/webui/blueprints/character_edit/templates/character_edit/EditProfile.jinja b/src/valentina/webui/blueprints/character_edit/templates/character_edit/EditProfile.jinja index b903a8e..438fcdf 100644 --- a/src/valentina/webui/blueprints/character_edit/templates/character_edit/EditProfile.jinja +++ b/src/valentina/webui/blueprints/character_edit/templates/character_edit/EditProfile.jinja @@ -11,8 +11,6 @@ Edit {{ character.full_name }} - {{ form }} -
diff --git a/src/valentina/webui/blueprints/character_edit/templates/character_edit/NoteForm.jinja b/src/valentina/webui/blueprints/character_edit/templates/character_edit/NoteForm.jinja new file mode 100644 index 0000000..2749ac9 --- /dev/null +++ b/src/valentina/webui/blueprints/character_edit/templates/character_edit/NoteForm.jinja @@ -0,0 +1,25 @@ +{# def + form:QuartForm, + join_label:bool = False, + floating_label:bool = False, + post_url:str = "", + character:Character, +#} + +
+
+
+ + Cancel + +
+
diff --git a/src/valentina/webui/blueprints/character_view/templates/character_view/Info.jinja b/src/valentina/webui/blueprints/character_view/templates/character_view/Info.jinja index 179694e..5d13756 100644 --- a/src/valentina/webui/blueprints/character_view/templates/character_view/Info.jinja +++ b/src/valentina/webui/blueprints/character_view/templates/character_view/Info.jinja @@ -1,24 +1,113 @@ {# def character: Character #} +

Biography

{% if character.bio %} -

Biography

- {{ character.bio | from_markdown | safe }} -{% endif %} -{% if character.notes %} -

Notes

-
    - {% for note in character.notes %}
  • {{ note.text | from_markdown | safe }}
  • {% endfor %} -
-{% endif %} -{% if character.sheet_sections %} -

Sheet Sections

-
- {% for section in character.sheet_sections | sort(attribute="title") %} -
-

{{ section.title | escape }}

- {{ section.content | from_markdown | safe }} -
- {% endfor %} +
{{ character.bio | from_markdown | safe }}
+{% else %} +
+

+ No biography + {% if session["IS_STORYTELLER"] or session["USER_ID"] == character.user_owner %} +
+ Write one by editing the profile. + {% endif %} +

{% endif %} +
+ +

+ Notes  Quick notes about the character +

+
+
+ {% if character.notes %} +
    + {% for note in character.notes %} +
  • + {{ note.text | from_markdown_no_p | safe }} + {% if session["IS_STORYTELLER"] or session["USER_ID"] == character.user_owner %} +
    + + +
    + {% endif %} + +
  • + {% endfor %} +
+ {% else %} +
+

No notes

+
+ {% endif %} +
+ +
+ +

+ Sheet Sections  Information about the character. Can have multiple paragraphs and use markdown for formatting +

+ +
+ +
+ {% if character.sheet_sections %} +
+ {% for section in character.sheet_sections | sort(attribute="title") %} +
+
+
+

{{ section.title | escape }}

+
+
{{ section.content | from_markdown | safe }}
+ {% if session["IS_STORYTELLER"] or session["USER_ID"] == character.user_owner %} + + {% endif %} +
+
+ + {% endfor %} +
+ {% else %} +
+

No custom sections

+
+ {% endif %} +
diff --git a/src/valentina/webui/utils/jinjax.py b/src/valentina/webui/utils/jinjax.py index 1eca236..a257f4d 100644 --- a/src/valentina/webui/utils/jinjax.py +++ b/src/valentina/webui/utils/jinjax.py @@ -1,5 +1,7 @@ """Configure Jinjax for Valentina.""" +import re + import jinjax from loguru import logger from markdown2 import markdown @@ -24,6 +26,12 @@ def from_markdown(value: str) -> str: return markdown(value) +def from_markdown_no_p(value: str) -> str: + """Strip enclosing paragraph marks,

...

, which markdown() forces, and which interfere with some jinja2 layout.""" + value = escape(value) + return re.sub("(^

|

$)", "", markdown(value), flags=re.IGNORECASE) + + def register_jinjax_catalog() -> jinjax.Catalog: """Register the JinJax catalog with the Quart application. @@ -44,6 +52,7 @@ def register_jinjax_catalog() -> jinjax.Catalog: catalog.add_folder(template_folder) catalog.jinja_env.filters.update({"from_markdown": from_markdown}) + catalog.jinja_env.filters.update({"from_markdown_no_p": from_markdown_no_p}) catalog.jinja_env.trim_blocks = True catalog.jinja_env.lstrip_blocks = True