Compare commits

...

4 commits

13 changed files with 271 additions and 64 deletions

View file

@ -1,5 +1,5 @@
from django.contrib.auth import views as auth_views
from django.urls import path
from django.urls import path, reverse_lazy
from . import views
@ -8,7 +8,7 @@ urlpatterns = [
# https://docs.djangoproject.com/en/2.2/topics/auth/default/#module-django.contrib.auth.views
path("login/", auth_views.LoginView.as_view(), name="login"),
path("logout/", auth_views.LogoutView.as_view(), name="logout"),
path("password_change/", auth_views.PasswordChangeView.as_view(success_url="/"), name="password_change"),
path("password_change/", auth_views.PasswordChangeView.as_view(success_url=reverse_lazy("user_profile")), name="password_change"),
path("signup/", views.SignupView.as_view(), name="signup"),
]

View file

@ -6,10 +6,8 @@ from django import forms
class BankImportForm(forms.Form):
import_string = forms.CharField(widget=forms.Textarea)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper.layout = Layout(
helper = FormHelper()
helper.layout = Layout(
Field("import_string"),
Submit("submit", "Import", css_class="btn btn-block btn-primary")
Submit("submit", "Import", css_class="btn btn-block btn-primary"),
)

View file

@ -32,7 +32,7 @@
{% endif %}
</div>
{% if user.is_authenticated %}
<span class="navbar-text">Logged in as <strong>{{ user.username }}</strong></span>
<span class="navbar-text">Logged in as <a class="font-weight-bold" href="{% url 'user_profile' %}">{{ user.username }}</a></span>
<a class="btn btn-outline-danger ml-3" role="button" href="{% url 'logout' %}">Log Out</a>
{% else %}
<a class="btn btn-outline-info mr-1" role="button" href="{% url 'signup' %}">Sign Up</a>

View file

@ -0,0 +1,63 @@
{% comment %}
Based on Crispy's default bootstrap4/table_inline_formset.html, but removed 'table-striped'
https://github.com/django-crispy-forms/django-crispy-forms/blob/master/crispy_forms/templates/bootstrap4/table_inline_formset.html
{% endcomment %}
{% load crispy_forms_tags %}
{% load crispy_forms_utils %}
{% load crispy_forms_field %}
{% specialspaceless %}
{% if formset_tag %}
<form {{ flat_attrs|safe }} method="{{ form_method }}" {% if formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
{% endif %}
{% if formset_method|lower == 'post' and not disable_csrf %}
{% csrf_token %}
{% endif %}
<div>
{{ formset.management_form|crispy }}
</div>
<table{% if form_id %} id="{{ form_id }}_table"{% endif%} class="table table-sm">
<thead>
{% if formset.readonly and not formset.queryset.exists %}
{% else %}
<tr>
{% for field in formset.forms.0 %}
{% if field.label and not field.is_hidden %}
<th for="{{ field.auto_id }}" class="col-form-label {% if field.field.required %}requiredField{% endif %}">
{{ field.label|safe }}{% if field.field.required and not field|is_checkbox %}<span class="asteriskField">*</span>{% endif %}
</th>
{% endif %}
{% endfor %}
</tr>
{% endif %}
</thead>
<tbody>
<tr class="d-none empty-form">
{% for field in formset.empty_form %}
{% include 'bootstrap4/field.html' with tag="td" form_show_labels=False %}
{% endfor %}
</tr>
{% for form in formset %}
{% if form_show_errors and not form.is_extra %}
{% include "bootstrap4/errors.html" %}
{% endif %}
<tr>
{% for field in form %}
{% include 'bootstrap4/field.html' with tag="td" form_show_labels=False %}
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
{% include "bootstrap4/inputs.html" %}
{% if formset_tag %}</form>{% endif %}
{% endspecialspaceless %}

View file

@ -31,7 +31,7 @@ class RaidResponseForm(ModelForm):
type="submit",
name="status",
value=RaidResponse.Status.SIGNED_UP,
css_class=f"btn-block {'btn-success' if not is_signed_up else 'btn-primary'}"
css_class=f"btn btn-block {'btn-success' if not is_signed_up else 'btn-primary'}"
),
css_class="form-group col-md-3"
),
@ -41,7 +41,7 @@ class RaidResponseForm(ModelForm):
type="submit",
name="status",
value=RaidResponse.Status.BACKUP,
css_class="btn-warning btn-block",
css_class="btn btn-block btn-warning",
disabled=self.instance.pk and self.instance.status == RaidResponse.Status.BACKUP
),
css_class="form-group col-md-2"
@ -52,7 +52,7 @@ class RaidResponseForm(ModelForm):
type="submit",
name="status",
value=RaidResponse.Status.SIGNED_OFF,
css_class="btn-danger btn-block",
css_class="btn btn-block btn-danger",
disabled=self.instance.pk and self.instance.status == RaidResponse.Status.SIGNED_OFF
),
css_class="form-group col-md-2"
@ -72,14 +72,13 @@ class GuestRaidResponseForm(ModelForm):
class Meta:
model = RaidResponse
fields = ["guest_name", "guest_klass", "role", "status"]
labels = {
"guest_name": "Name",
"guest_klass": "Class"
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["status"].initial = RaidResponse.Status.SIGNED_UP
self.helper = FormHelper()
self.helper.layout = Layout(
helper = FormHelper()
helper.layout = Layout(
Row(
Column("guest_name", css_class="col-12 col-md-3"),
Column("guest_klass", css_class="col-12 col-md-2"),
@ -87,19 +86,22 @@ class GuestRaidResponseForm(ModelForm):
Column("status", css_class="col-12 col-md-3"),
Column(
HTML("<label>&nbsp;</label>"), # offsets the button consistent with the other input elements
StrictButton(
Submit(
"submit",
"Add",
type="submit",
css_class=f"btn-block btn-primary"
css_class="btn btn-block btn-primary"
),
css_class="form-group col-md-2"
),
css_class="form-row"
)
)
self.helper.render_hidden_fields = True # Include MultiFormMixin's hidden form_id_field_prefix
self.helper.form_show_labels = True
self.helper.form_tag = False
helper.render_hidden_fields = True # Include MultiFormMixin's hidden form_id_field_prefix
helper.form_tag = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["status"].initial = RaidResponse.Status.SIGNED_UP
class RaidResponseChangeForm(ModelForm):
@ -118,8 +120,8 @@ class RaidForm(ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper = FormHelper()
if self.instance.pk is None:
submit_button = Submit("submit", "Create", css_class="btn btn-success float-right")
else:
@ -134,16 +136,16 @@ class RaidForm(ModelForm):
),
submit_button
)
self.helper.render_hidden_fields = True
self.helper.render_hidden_fields = True # Include MultiFormMixin's hidden form_id_field_prefix
class RaidCommentForm(ModelForm):
helper = FormHelper()
helper.layout = Layout(
Field("body", label="lol"),
Submit("submit", "Submit"),
Field("body"),
Submit("submit", "Submit", css_class="btn btn-primary"),
)
helper.render_hidden_fields = True
helper.render_hidden_fields = True # Include MultiFormMixin's hidden form_id_field_prefix
helper.form_show_labels = False
class Meta:
@ -163,9 +165,7 @@ RaidResponseFormSet = inlineformset_factory(
class RaidResponseFormSetHelper(FormHelper):
template = "raids/raid_response_table_inline_formset.html"
def __init__(self, form=None):
super().__init__(form)
self.layout = Layout(
layout = Layout(
Field("character", css_class="character-select"),
Field("guest_name", css_class="guest-name"),
Field("guest_klass", css_class="guest-klass"),
@ -175,4 +175,4 @@ class RaidResponseFormSetHelper(FormHelper):
Field("note"),
Field("attendance", css_class="attendance-input", style="width: 10ch"),
)
self.form_tag = False
form_tag = False

View file

@ -209,13 +209,13 @@ class RaidChangeView(PermissionRequiredMixin, SingleObjectMixin, MultiModelFormV
def get_raid_response_formset_instance(self):
return self.object
def get_success_url(self):
return reverse("raid_detail", kwargs={"pk": self.object.pk})
def get_context_data(self, **kwargs):
kwargs["raid_response_formset_helper"] = RaidResponseFormSetHelper()
return super().get_context_data(**kwargs)
def get_success_url(self):
return reverse("raid_detail", kwargs={"pk": self.object.pk})
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super().get(request, *args, **kwargs)

View file

@ -1,6 +1,7 @@
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from .forms import UserCharactersInlineFormSet
from .models import User, Character, Rank
@ -12,6 +13,7 @@ class RankAdmin(admin.ModelAdmin):
class CharacterInline(admin.TabularInline):
model = Character
formset = UserCharactersInlineFormSet # disallows deleting user's main character
fields = ["name", "klass", "role"]
extra = 0

View file

@ -1,6 +1,10 @@
from django.forms import ModelForm
from crispy_forms.helper import FormHelper
from crispy_forms.layout import Layout, Field
from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
from django.forms import ModelForm, inlineformset_factory, BaseInlineFormSet
from .models import Character
from ..users.models import User
class CharacterForm(ModelForm):
@ -8,3 +12,51 @@ class CharacterForm(ModelForm):
model = Character
fields = ["name", "klass", "role"]
class UserForm(ModelForm):
class Meta:
model = User
fields = ["username", "rank", "main"]
def __init__(self, user, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["rank"].disabled = True
self.fields["main"].queryset = user.characters
helper = FormHelper()
helper.layout = Layout(
Field("username"),
Field("rank"),
Field("main"),
)
helper.render_hidden_fields = True # Include MultiFormMixin's hidden form_id_field_prefix
helper.form_tag = False
class UserCharactersInlineFormSet(BaseInlineFormSet):
def clean(self):
super().clean()
for form in self.deleted_forms:
if form.instance == self.instance.main:
form._errors[NON_FIELD_ERRORS] = self.error_class(["Can't delete user's main character."])
raise ValidationError(["Can't delete user's main character."])
UserCharactersFormSet = inlineformset_factory(
User, # parent model
Character,
formset=UserCharactersInlineFormSet,
fields=["name", "klass", "role"],
can_delete=True,
extra=1
)
class UserCharactersFormSetHelper(FormHelper):
template = "bootstrap4/table_inline_formset.html"
layout = Layout(
Field("name"),
Field("klass"),
Field("role")
)
form_tag = False

View file

@ -0,0 +1,19 @@
# Generated by Django 3.1.1 on 2020-09-24 20:19
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('users', '0005_auto_20191121_1646'),
]
operations = [
migrations.AlterField(
model_name='user',
name='main',
field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='users.character'),
),
]

View file

@ -33,8 +33,7 @@ class User(AbstractUser):
main = models.OneToOneField(
"Character",
related_name="+",
on_delete=models.CASCADE,
blank=True
on_delete=models.CASCADE # has to cascade to allow User deletion, since 'main' Character is circularly related
)
rank = models.ForeignKey(
@ -124,5 +123,12 @@ class Character(models.Model):
def clean(self):
self.name = self.name.capitalize()
def delete(self, *args, **kwargs):
if self.user.main == self:
# Sanity check: this is already checked by the profile and admin forms which allow character deletion, but
# we check it again to avoid unintentionally cascading the delete to the entire user.
raise ValidationError("Can't delete user's main character.")
return super().delete(*args, **kwargs)
def __str__(self):
return self.name

View file

@ -0,0 +1,30 @@
{% extends "base.html" %}
{% load crispy_forms_tags %}
{% block title %}Profile{% endblock %}
{% block content %}
<div class="container">
<div class="card mb-3">
<h5 class="card-header">Profile</h5>
<div class="card-body">
<form method="post" action="{% url 'user_profile' %}">
{% crispy user_form %}
<div class="d-flex justify-content-end">
<a class="btn btn-outline-info float-right mr-2" role="button" href="{% url 'password_change' %}">Change Password</a>
<input class="btn btn-primary float-right" type="submit" name="submit" value="Save">
</div>
</form>
<div class="my-4"></div>
<h5>Characters</h5>
<hr>
<form method="post" class="table-borderless" action="{% url 'user_profile' %}">
{% crispy user_characters_formset user_characters_formset_helper %}
<input class="btn btn-primary float-right" type="submit" name="submit" value="Save">
</form>
</div>
</div>
</div>
{% endblock %}

View file

@ -4,4 +4,5 @@ from . import views
urlpatterns = [
path("attendance/", views.AttendanceView.as_view(), name="attendance"),
path("profile/", views.UserProfileView.as_view(), name="user_profile"),
]

View file

@ -1,11 +1,16 @@
from contextlib import suppress
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Avg, Q, Value
from django.db.models.functions import Coalesce
from django.urls import reverse
from django.utils import timezone
from django.views.generic import TemplateView
from django.views.generic.detail import SingleObjectMixin
from .forms import UserForm, UserCharactersFormSet, UserCharactersFormSetHelper
from .models import User
from ..base.views import MultiModelFormView
from ..raids.models import Raid, RaidResponse
@ -47,6 +52,37 @@ class AttendanceView(TemplateView):
return context
# CharacterDetailView:
#slug_field = "title"
#slug_url_kwarg = "title"
class UserProfileView(LoginRequiredMixin, SingleObjectMixin, MultiModelFormView):
template_name = "users/profile.html"
form_classes = {
"user_form": UserForm,
"user_characters_formset": UserCharactersFormSet
}
def get_object(self, queryset=None):
return self.request.user
def get_user_form_instance(self):
return self.object
def get_user_characters_formset_instance(self):
return self.object
def get_user_form_kwargs(self, kwargs):
kwargs["user"] = self.request.user
return kwargs
def get_context_data(self, **kwargs):
kwargs["user_characters_formset_helper"] = UserCharactersFormSetHelper()
return super().get_context_data(**kwargs)
def get_success_url(self):
return reverse("user_profile")
def get(self, request, *args, **kwargs):
self.object = self.get_object()
return super().get(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
self.object = self.get_object()
return super().post(request, *args, **kwargs)