Skip to content

Commit

Permalink
Add a page allowing users to edit a replay (#484)
Browse files Browse the repository at this point in the history
* Add a custom field type for Django enums.

The problem that caused me to write this PR is that enums get made available
on Django forms as strings. This does work eventually; the string gets set
on the model and is handled appropriately. But it's really annoying to
work with programmatically. For example, equality checks fail in places
you'd expect them to succeed.

* Add a page that allows you to edit an existing replay.

The edit replay page can be used to edit the metadata of an existing replay.
I reuse the "finalize publishing replay" page for this purpose. You can't
change the actual file, but you can change everything else.

PC-98 games aren't supported, not for any particular reason but because
this PR is big enough as it is.

* Last-minute fixes

* Respond to code review comments

* Add test for posting an invalid replay edit.

* Oops. Fix duplicate name
  • Loading branch information
n-rook authored Dec 27, 2023
1 parent 44767f8 commit ceb387e
Show file tree
Hide file tree
Showing 14 changed files with 598 additions and 148 deletions.
70 changes: 69 additions & 1 deletion project/thscoreboard/replays/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Various forms useful for the replays site."""

from typing import Any, List, Sequence, Tuple
from typing import Any, List, Mapping, Optional, Sequence, Tuple
from urllib import parse

from django import forms
Expand All @@ -24,6 +24,10 @@
)


class UnboundFormError(Exception):
"""A form was unbound, but this operation requires it be bound."""


_ALLOWED_REPLAY_HOSTS = [
"www.youtube.com",
"youtu.be",
Expand Down Expand Up @@ -124,6 +128,43 @@ class UploadReplayFileForm(forms.Form):
replay_file = forms.FileField()


def initialize_publish_replay_form_from_replay(
r: models.Replay, data: Optional[Mapping[str, Any]] = None
) -> "PublishReplayForm":
"""Return a new PublishReplayForm based on an existing replay.
Information from the Replay is used to populate the "initial" data. Note
that initial data is not used as a fallback for real values from a
populated form. See
https://docs.djangoproject.com/en/4.2/ref/forms/fields/#initial for more
detail.
Args:
r: The replay to use as initial values.
data: If present, form data from a POST used to populate the Form.
Returns:
A PublishReplayForm.
"""

return PublishReplayForm(
r.shot.game.game_id,
r.replay_type,
initial={
"name": r.name,
"score": r.score,
"category": r.category,
"comment": r.comment,
"is_good": r.is_good,
"is_clear": r.is_clear,
"video_link": r.video_link,
"uses_bombs": not r.no_bomb,
"misses": r.miss_count,
},
data=data,
)


class PublishReplayForm(forms.Form):
def __init__(self, gameID: str, replay_type: models.ReplayType, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand Down Expand Up @@ -160,6 +201,33 @@ def clean(self):
),
)

def update_replay(self, r: models.Replay):
"""Update an existing Replay row with information from this form.
This form must be bound and cleaned. This method does not save
the updated replay.
Args:
r: The replay to update.
"""
if not self.is_bound:
raise UnboundFormError()

r.score = self.cleaned_data["score"]
r.category = self.cleaned_data["category"]
r.comment = self.cleaned_data["comment"]
r.is_good = self.cleaned_data["is_good"]
r.is_clear = self.cleaned_data["is_clear"]
if "uses_bombs" in self.cleaned_data:
r.no_bomb = not self.cleaned_data["uses_bombs"]
else:
r.no_bomb = None
if "misses" in self.cleaned_data:
r.miss_count = self.cleaned_data["misses"]
else:
r.miss_count = None
r.video_link = self.cleaned_data["video_link"]


class PublishReplayWithoutFileForm(forms.Form):
def __init__(self, *args, game: models.Game, **kwargs):
Expand Down
4 changes: 4 additions & 0 deletions project/thscoreboard/replays/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,10 @@ def GetDifficultyUrlCode(self):
replay_type = models.IntegerField(choices=ReplayType.choices)
"""Type of replay (full run run, stage practice, etc)"""

def GetReplayTypeName(self) -> str:
"""Returns a string description of this replay's type."""
return game_ids.GetReplayType(self.replay_type)

no_bomb = models.BooleanField(blank=True, null=True)
"""Whether the replay uses no bombs (a popular challenge condition).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
{% extends "base.html" %}
{% load i18n %}

{% block content %}
<h1>{% block publish_or_edit_heading %}{% endblock %}</h1>
<h2>{{ game_name }}</h2>
<p>
<table class="replay-header">
<tbody>
{% if replay_timestamp %}
<tr>
<td>{% translate "Replay Date" %}</td>
{% if game_id == "th06" or game_id == "th09" %}
<td>{{ replay_timestamp|date:"d F Y" }}</td>
{% elif game_id == "th07" %}
<td>{{ replay_timestamp|date:"d F" }}</td>
{% else %}
<td>{{ replay_timestamp|date:"d F Y, h:i A" }}</td>
{% endif %}
</tr>
{% endif %}
<tr>
<td>{% translate "Difficulty" %}</td>
<td>{{ difficulty_name }}</td>
</tr>
<tr>
<td>{% translate "Shot" %}</td>
<td>{{ shot_name }}</td>
</tr>
{% if route_name %}
<tr>
<td>{% translate "Route" %}</td>
<td>{{ route_name }}</td>
</tr>
{% endif %}
{% if replay_slowdown is not None %}
<tr>
<td>{% translate "Slowdown" %}</td>
<td>{{ replay_slowdown|floatformat:4 }}%</td>
</tr>
{% endif %}
<tr>
<td>{% translate "Replay Type" %}</td>
<td>{{ replay_type }}</td>
</tr>
{% if replay_spell_card_id %}
<tr>
<td>{% translate "Spell Card ID" %}</td>
<td>{{ replay_spell_card_id }}</td>
</tr>
{% endif %}
</tbody>
</table>
</p>
<form method="post" class="finish-uploading-form">
{% csrf_token %}
{{ form.non_field_errors }}

<table class="form-table">
<th style="width: 50%;"></th>
<tr>
<td>
<label for="{{form.name.id_for_label}}">{% translate "Name" %}</label>
{{ form.name.errors }}
</td>
<td>
{{ form.name }}
</td>
</tr>
<tr>
<td>
<label for="{{form.score.id_for_label}}">{% translate "Score" %}</label>
{{ form.score.errors }}
</td>
<td>
{{ form.score }}
</td>
</tr>
<tr>
<td>
<label for="{{form.category.id_for_label}}">{% translate "Category" %}</label>
{{ form.category.errors }}
</td>
<td>
{{ form.category }}
</td>
</tr>
<tr>
<td>
<label for="{{form.video_link.id_for_label}}">{% translate "Video link" %}</label>
{{ form.video_link.errors }}
</td>
<td>
{{ form.video_link }}
</td>
</tr>
<tr>
<td>
<label for="{{form.is_clear.id_for_label}}">{% translate "Did it clear?" %}</label>
</td>
<td>
{{ form.is_clear }}
</td>
</tr>
{% if has_replay_file %}
<tr>
<td>
<label for="{{form.is_good.id_for_label}}">{% translate "No desyncs" %}</label>
{{ form.is_good.errors }}
</td>
<td>
{{ form.is_good }}
</td>
</tr>
{% endif %}
{% if form.uses_bombs %}
<tr>
<td>
<label for="{{form.uses_bombs.id_for_label}}">{% translate "Did you use bombs?" %}</label>
{{ form.uses_bombs.errors }}
</td>
<td>
{{ form.uses_bombs }}
</td>
</tr>
{% endif %}
{% if form.misses %}
<tr>
<td>
<label for="{{form.misses.id_for_label}}">{% translate "Miss count (optional)" %}</label>
{{ form.misses.errors }}
</td>
<td>
{{ form.misses }}
</td>
</tr>
{% endif %}
</table>
<div class="comment-input">
<label for="{{form.comment.id_for_label}}">{% translate "Comment" %}</label> {{ form.comment.errors }}<br>
{{ form.comment }}
</div>
<input type="submit" value={% translate "Publish Replay" %}>
</form>
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% extends "replays/base/publish_or_edit.html" %}
{% load i18n %}

{% block publish_or_edit_heading %}{% translate "Edit your replay" %}{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% extends "simple_centered.html" %}
{% load i18n %}

{% block centered-content %}
<p>{% blocktranslate %}You cannot edit this replay; only its owner can edit it.{% endblocktranslate %}</p>
<p><a href="{% url 'Replays/Details' game_id replay_id %}">{% translate "Go back to the replay" %}</a></p>
{% endblock %}
Loading

0 comments on commit ceb387e

Please sign in to comment.