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.contrib.auth import views as auth_views
from django.urls import path from django.urls import path, reverse_lazy
from . import views from . import views
@ -8,7 +8,7 @@ urlpatterns = [
# https://docs.djangoproject.com/en/2.2/topics/auth/default/#module-django.contrib.auth.views # 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("login/", auth_views.LoginView.as_view(), name="login"),
path("logout/", auth_views.LogoutView.as_view(), name="logout"), 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"), path("signup/", views.SignupView.as_view(), name="signup"),
] ]

View file

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

View file

@ -32,7 +32,7 @@
{% endif %} {% endif %}
</div> </div>
{% if user.is_authenticated %} {% 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> <a class="btn btn-outline-danger ml-3" role="button" href="{% url 'logout' %}">Log Out</a>
{% else %} {% else %}
<a class="btn btn-outline-info mr-1" role="button" href="{% url 'signup' %}">Sign Up</a> <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", type="submit",
name="status", name="status",
value=RaidResponse.Status.SIGNED_UP, 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" css_class="form-group col-md-3"
), ),
@ -41,7 +41,7 @@ class RaidResponseForm(ModelForm):
type="submit", type="submit",
name="status", name="status",
value=RaidResponse.Status.BACKUP, 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 disabled=self.instance.pk and self.instance.status == RaidResponse.Status.BACKUP
), ),
css_class="form-group col-md-2" css_class="form-group col-md-2"
@ -52,7 +52,7 @@ class RaidResponseForm(ModelForm):
type="submit", type="submit",
name="status", name="status",
value=RaidResponse.Status.SIGNED_OFF, 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 disabled=self.instance.pk and self.instance.status == RaidResponse.Status.SIGNED_OFF
), ),
css_class="form-group col-md-2" css_class="form-group col-md-2"
@ -72,35 +72,37 @@ class GuestRaidResponseForm(ModelForm):
class Meta: class Meta:
model = RaidResponse model = RaidResponse
fields = ["guest_name", "guest_klass", "role", "status"] fields = ["guest_name", "guest_klass", "role", "status"]
labels = {
"guest_name": "Name",
"guest_klass": "Class"
}
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"),
Column("role", css_class="col-12 col-md-2"),
Column("status", css_class="col-12 col-md-3"),
Column(
HTML("<label>&nbsp;</label>"), # offsets the button consistent with the other input elements
Submit(
"submit",
"Add",
css_class="btn btn-block btn-primary"
),
css_class="form-group col-md-2"
),
css_class="form-row"
)
)
helper.render_hidden_fields = True # Include MultiFormMixin's hidden form_id_field_prefix
helper.form_tag = False
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["status"].initial = RaidResponse.Status.SIGNED_UP self.fields["status"].initial = RaidResponse.Status.SIGNED_UP
self.helper = FormHelper()
self.helper.layout = Layout(
Row(
Column("guest_name", css_class="col-12 col-md-3"),
Column("guest_klass", css_class="col-12 col-md-2"),
Column("role", css_class="col-12 col-md-2"),
Column("status", css_class="col-12 col-md-3"),
Column(
HTML("<label>&nbsp;</label>"), # offsets the button consistent with the other input elements
StrictButton(
"Add",
type="submit",
css_class=f"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
class RaidResponseChangeForm(ModelForm): class RaidResponseChangeForm(ModelForm):
class Meta: class Meta:
@ -118,8 +120,8 @@ class RaidForm(ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.helper = FormHelper()
self.helper = FormHelper()
if self.instance.pk is None: if self.instance.pk is None:
submit_button = Submit("submit", "Create", css_class="btn btn-success float-right") submit_button = Submit("submit", "Create", css_class="btn btn-success float-right")
else: else:
@ -134,16 +136,16 @@ class RaidForm(ModelForm):
), ),
submit_button 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): class RaidCommentForm(ModelForm):
helper = FormHelper() helper = FormHelper()
helper.layout = Layout( helper.layout = Layout(
Field("body", label="lol"), Field("body"),
Submit("submit", "Submit"), 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 helper.form_show_labels = False
class Meta: class Meta:
@ -163,16 +165,14 @@ RaidResponseFormSet = inlineformset_factory(
class RaidResponseFormSetHelper(FormHelper): class RaidResponseFormSetHelper(FormHelper):
template = "raids/raid_response_table_inline_formset.html" template = "raids/raid_response_table_inline_formset.html"
def __init__(self, form=None): layout = Layout(
super().__init__(form) Field("character", css_class="character-select"),
self.layout = Layout( Field("guest_name", css_class="guest-name"),
Field("character", css_class="character-select"), Field("guest_klass", css_class="guest-klass"),
Field("guest_name", css_class="guest-name"), Field("role", css_class="role-select"),
Field("guest_klass", css_class="guest-klass"), Field("status", css_class="status-select"),
Field("role", css_class="role-select"), Field("group", css_class="group-select"),
Field("status", css_class="status-select"), Field("note"),
Field("group", css_class="group-select"), Field("attendance", css_class="attendance-input", style="width: 10ch"),
Field("note"), )
Field("attendance", css_class="attendance-input", style="width: 10ch"), form_tag = False
)
self.form_tag = False

View file

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

View file

@ -1,6 +1,7 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
from .forms import UserCharactersInlineFormSet
from .models import User, Character, Rank from .models import User, Character, Rank
@ -12,6 +13,7 @@ class RankAdmin(admin.ModelAdmin):
class CharacterInline(admin.TabularInline): class CharacterInline(admin.TabularInline):
model = Character model = Character
formset = UserCharactersInlineFormSet # disallows deleting user's main character
fields = ["name", "klass", "role"] fields = ["name", "klass", "role"]
extra = 0 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 .models import Character
from ..users.models import User
class CharacterForm(ModelForm): class CharacterForm(ModelForm):
@ -8,3 +12,51 @@ class CharacterForm(ModelForm):
model = Character model = Character
fields = ["name", "klass", "role"] 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( main = models.OneToOneField(
"Character", "Character",
related_name="+", related_name="+",
on_delete=models.CASCADE, on_delete=models.CASCADE # has to cascade to allow User deletion, since 'main' Character is circularly related
blank=True
) )
rank = models.ForeignKey( rank = models.ForeignKey(
@ -124,5 +123,12 @@ class Character(models.Model):
def clean(self): def clean(self):
self.name = self.name.capitalize() 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): def __str__(self):
return self.name 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 = [ urlpatterns = [
path("attendance/", views.AttendanceView.as_view(), name="attendance"), 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 contextlib import suppress
from django.contrib.auth.mixins import LoginRequiredMixin
from django.db.models import Avg, Q, Value from django.db.models import Avg, Q, Value
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django.views.generic.detail import SingleObjectMixin
from .forms import UserForm, UserCharactersFormSet, UserCharactersFormSetHelper
from .models import User from .models import User
from ..base.views import MultiModelFormView
from ..raids.models import Raid, RaidResponse from ..raids.models import Raid, RaidResponse
@ -47,6 +52,37 @@ class AttendanceView(TemplateView):
return context return context
# CharacterDetailView: class UserProfileView(LoginRequiredMixin, SingleObjectMixin, MultiModelFormView):
#slug_field = "title" template_name = "users/profile.html"
#slug_url_kwarg = "title" 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)