Merge pull request #1671 from strikaco/modernize

Frontend CRUD enhancements (character/account creation and management)
This commit is contained in:
Griatch 2018-10-26 20:21:19 +02:00 committed by GitHub
commit 0bbe96b119
29 changed files with 1310 additions and 70 deletions

View file

@ -196,6 +196,19 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
@lazy_property
def sessions(self):
return AccountSessionHandler(self)
# Do not make this a lazy property; the web UI will not refresh it!
@property
def characters(self):
# Get playable characters list
objs = self.db._playable_characters
# Rebuild the list if legacy code left null values after deletion
if None in objs:
objs = [x for x in self.db._playable_characters if x]
self.db._playable_characters = objs
return objs
# session-related methods
@ -720,7 +733,8 @@ class DefaultAccount(with_metaclass(TypeclassBase, AccountDB)):
if character:
# Update playable character list
account.db._playable_characters.append(character)
if character not in account.characters:
account.db._playable_characters.append(character)
# We need to set this to have @ic auto-connect to this character
account.db._last_puppet = character

View file

@ -1944,12 +1944,20 @@ class DefaultCharacter(DefaultObject):
locks = kwargs.pop('locks', '')
try:
# Check to make sure account does not have too many chars
if len(account.characters) >= settings.MAX_NR_CHARACTERS:
errors.append("There are too many characters associated with this account.")
return obj, errors
# Create the Character
obj = create.create_object(**kwargs)
# Record creator id and creation IP
if ip: obj.db.creator_ip = ip
if account: obj.db.creator_id = account.id
if account:
obj.db.creator_id = account.id
if obj not in account.characters:
account.db._playable_characters.append(obj)
# Add locks
if not locks and account:
@ -1963,7 +1971,7 @@ class DefaultCharacter(DefaultObject):
# If no description is set, set a default description
if description or not obj.db.desc:
obj.db.desc = description if description else "This is a character."
except Exception as e:
errors.append("An error occurred while creating this '%s' object." % key)
logger.log_err(e)

View file

@ -755,6 +755,7 @@ TEMPLATES = [{
'django.contrib.auth.context_processors.auth',
'django.template.context_processors.media',
'django.template.context_processors.debug',
'django.contrib.messages.context_processors.messages',
'sekizai.context_processors.sekizai',
'evennia.web.utils.general_context.general_context'],
# While true, show "pretty" error messages for template syntax errors.
@ -789,6 +790,7 @@ INSTALLED_APPS = (
'django.contrib.flatpages',
'django.contrib.sites',
'django.contrib.staticfiles',
'django.contrib.messages',
'sekizai',
'evennia.utils.idmapper',
'evennia.server',

View file

@ -817,6 +817,38 @@ class TypedObject(SharedMemoryModel):
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
except:
return '#'
def web_get_puppet_url(self):
"""
Returns the URI path for a View that allows users to puppet a specific
object.
ex. Oscar (Character) = '/characters/oscar/1/puppet/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'character-puppet' would be referenced by this method.
ex.
url(r'characters/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/puppet/$',
CharPuppetView.as_view(), name='character-puppet')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can view this object is the developer's
responsibility.
Returns:
path (str): URI path to object puppet page, if defined.
"""
try:
return reverse('%s-puppet' % self._meta.verbose_name.lower(),
kwargs={'pk': self.pk, 'slug': slugify(self.name)})
except:
return '#'
def web_get_update_url(self):
"""

View file

@ -67,7 +67,17 @@ def general_context(request):
Returns common Evennia-related context stuff, which
is automatically added to context of all views.
"""
account = None
if request.user.is_authenticated(): account = request.user
puppet = None
if account and request.session.get('puppet'):
pk = int(request.session.get('puppet'))
puppet = next((x for x in account.characters if x.pk == pk), None)
return {
'account': account,
'puppet': puppet,
'game_name': GAME_NAME,
'game_slogan': GAME_SLOGAN,
'evennia_userapps': ACCOUNT_RELATED,

View file

@ -1,10 +1,8 @@
from mock import Mock, patch
from django.test import TestCase
from django.contrib.auth.models import AnonymousUser
from django.test import RequestFactory, TestCase
from mock import MagicMock, patch
from . import general_context
class TestGeneralContext(TestCase):
maxDiff = None
@ -15,8 +13,18 @@ class TestGeneralContext(TestCase):
@patch('evennia.web.utils.general_context.WEBSOCKET_PORT', "websocket_client_port_testvalue")
@patch('evennia.web.utils.general_context.WEBSOCKET_URL', "websocket_client_url_testvalue")
def test_general_context(self):
request = Mock()
self.assertEqual(general_context.general_context(request), {
request = RequestFactory().get('/')
request.user = AnonymousUser()
request.session = {
'account': None,
'puppet': None,
}
response = general_context.general_context(request)
self.assertEqual(response, {
'account': None,
'puppet': None,
'game_name': "test_name",
'game_slogan': "test_game_slogan",
'evennia_userapps': ['Accounts'],

View file

@ -0,0 +1,56 @@
from django import forms
from django.conf import settings
from django.contrib.auth.forms import UserCreationForm, UsernameField
from django.forms import ModelForm
from django.utils.html import escape
from evennia.utils import class_from_module
class EvenniaForm(forms.Form):
def clean(self):
cleaned = super(EvenniaForm, self).clean()
# Escape all values provided by user
cleaned = {k:escape(v) for k,v in cleaned.items()}
return cleaned
class AccountForm(EvenniaForm, UserCreationForm):
class Meta:
model = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
fields = ("username", "email")
field_classes = {'username': UsernameField}
email = forms.EmailField(help_text="A valid email address. Optional; used for password resets.", required=False)
class ObjectForm(EvenniaForm, ModelForm):
class Meta:
model = class_from_module(settings.BASE_OBJECT_TYPECLASS)
fields = ("db_key",)
labels = {
'db_key': 'Name',
}
class CharacterForm(ObjectForm):
class Meta:
# Get the correct object model
model = class_from_module(settings.BASE_CHARACTER_TYPECLASS)
# Allow entry of the 'key' field
fields = ("db_key",)
# Rename 'key' to something more intelligible
labels = {
'db_key': 'Name',
}
# Fields pertaining to user-configurable attributes on the Character object.
desc = forms.CharField(label='Description', widget=forms.Textarea(attrs={'rows': 3}), max_length=2048, required=False)
class CharacterUpdateForm(CharacterForm):
"""
Provides a form that only allows updating of db attributes, not model
attributes.
"""
pass

View file

@ -39,9 +39,28 @@ folder and edit it to add/remove links to the menu.
{% block navbar_right %}
{% endblock %}
{% block navbar_user %}
{% if user.is_authenticated %}
<li class="nav-item">
<a class="nav-link">Logged in as {{user.username}}</a>
{% if account %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle" data-toggle="dropdown" href="#" id="user_options" aria-expanded="false">
{% if puppet %}
Welcome, {{ puppet }}! <span class="text-muted">({{ account.username }})</span> <span class="caret"></span>
{% else %}
Logged in as {{ account.username }} <span class="caret"></span>
{% endif %}
</a>
<div class="dropdown-menu" aria-labelledby="user_options">
<a class="dropdown-item" href="{% url 'character-create' %}">Create Character</a>
<a class="dropdown-item" href="{% url 'character-manage' %}">Manage Characters</a>
<div class="dropdown-divider"></div>
{% for character in account.characters|slice:"10" %}
<a class="dropdown-item" href="{{ character.web_get_puppet_url }}?next={{ request.path }}">{{ character }}</a>
{% empty %}
<a class="dropdown-item" href="#">No characters found!</a>
{% endfor %}
<div class="dropdown-divider"></div>
<a class="dropdown-item" href="{% url 'password_change' %}">Change Password</a>
<a class="dropdown-item" href="{% url 'logout' %}">Log Out</a>
</div>
</li>
<li>
<a class="nav-link" href="{% url 'logout' %}">Log Out</a>
@ -51,7 +70,7 @@ folder and edit it to add/remove links to the menu.
<a class="nav-link" href="{% url 'login' %}">Log In</a>
</li>
<li>
<a class="nav-link" href="{% url 'to_be_implemented' %}">Register</a>
<a class="nav-link" href="{% url 'register' %}">Register</a>
</li>
{% endif %}
{% endblock %}

View file

@ -12,7 +12,7 @@
<link rel="icon" type="image/x-icon" href="/static/website/images/evennia_logo.png" />
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/css/bootstrap.min.css" integrity="sha384-/Y6pD6FV/Vv2HJnA6t+vslU6fwYXjCFtcEpHbNJ0lyAFsXTsjBbfaDjzALeQsN6M" crossorigin="anonymous">
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<!-- Base CSS -->
<link rel="stylesheet" type="text/css" href="{% static "website/css/app.css" %}">
@ -29,6 +29,8 @@
<title>{{game_name}} - {% if flatpage %}{{flatpage.title}}{% else %}{% block titleblock %}{{page_title}}{% endblock %}{% endif %}</title>
</head>
<body>
{% block body %}
<div id="top"><a href="#main-content" class="sr-only sr-only-focusable">Skip to main content.</a></div>
{% include "website/_menu.html" %}
<div class="container main-content mt-4" id="main-copy">
@ -40,8 +42,12 @@
</div>
{% endif %}
<div class="{% if sidebar %}col-8{% else %}col{% endif %}">
{% include 'website/messages.html' %}
{% block content %}
{% endblock %}
{% include 'website/pagination.html' %}
</div>
</div>
</div>
@ -53,10 +59,12 @@
</div>
{% endblock %}
</footer>
{% endblock %}
<!-- jQuery first, then Tether, then Bootstrap JS. -->
<script src="https://code.jquery.com/jquery-3.2.1.slim.min.js" integrity="sha384-KJ3o2DKtIkvYIK3UENzmM7KCkRr/rE9/Qpg6aAZGJwFDMVNA/GpGFF93hXpG5KkN" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.11.0/umd/popper.min.js" integrity="sha384-b/U6ypiBEHpOf/4+1nzFpr53nxSS+GLCkfwBdFNTxtclqqenISfwAzpKaMNFNmj4" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-beta/js/bootstrap.min.js" integrity="sha384-h0AbiXch4ZDo7tp9hKZ4TsHbi047NrKGLO3SEJAg45jXxnGIfYzk4Si90RDIqNm1" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.12.9/umd/popper.min.js" integrity="sha384-ApNbgh9B+Y1QKtv3Rn7W3mgPxhU9K/ScQsAP7hUibX39j7fakFPskvXusvfa0b4Q" crossorigin="anonymous"></script>
<script src="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/js/bootstrap.min.js" integrity="sha384-JZR6Spejh4U02d8jOt6vLEHfe/JQGiRRSQQxSfFWpi1MquVdAyjUar5+76PVCmYl" crossorigin="anonymous"></script>
</body>
</html>

View file

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }}
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
{% endfor %}
{% endif %}
<form method="post" action="?">
{% csrf_token %}
{% for field in form %}
<div class="form-field my-3">
{{ field.label_tag }}
{{ field | addclass:"form-control" }}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
{% endif %}
</div>
{% endfor %}
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Submit" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }}
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
{% for object in object_list %}
<div class="media mb-4">
<a href="{{ object.web_get_detail_url }}"><img class="d-flex mr-3" src="http://placehold.jp/50x50.png" alt="" /></a>
<div class="media-body">
<p class="float-right ml-2">{{ object.db_date_created }}
<br /><a href="{{ object.web_get_delete_url }}">Delete</a>
<br /><a href="{{ object.web_get_update_url }}">Edit</a></p>
<h5 class="mt-0"><a href="{{ object.web_get_detail_url }}">{{ object }}</a> {% if object.subtitle %}<small class="text-muted" style="white-space:nowrap;">{{ object.subtitle }}</small>{% endif %}</h5>
<p>{{ object.db.desc }}</p>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -6,7 +6,7 @@
<div class="row">
<div class="col">
<div class="card">
<div class="card-block">
<div class="card-block p-4">
<h1 class="card-title">Admin</h1>
<p class="card-text">
Welcome to the Evennia Admin Page. Here, you can edit many facets of accounts, characters, and other parts of the game.

View file

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block titleblock %}
Form
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Form</h1>
<hr />
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
{% endfor %}
{% endif %}
<form method="post" action="?">
{% csrf_token %}
{% for field in form %}
<div class="form-field my-3">
{{ field.label_tag }}
{{ field | addclass:"form-control" }}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
{% endif %}
</div>
{% endfor %}
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Submit" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,9 @@
{% if messages %}
<!-- messages -->
{% for message in messages %}
<div class="alert alert-{{ message.tags }}">
{{ message }}
</div>
{% endfor %}
<!-- end messages -->
{% endif %}

View file

@ -0,0 +1,33 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }}
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3 border border-danger">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
<form method="post" action="?">
{% csrf_token %}
<p>Are you sure you want to delete "{{ object }}"?</p>
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-danger" type="submit" value="yes" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block titleblock %}
List
{% endblock %}
{% block content %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">List</h1>
<hr />
<ul>
{% for object in object_list %}
<li>{{ object }}</li>
{% endfor %}
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,32 @@
{% if page_obj %}
<div class="row">
<div class="col">
<div class="card mt-3">
<div class="card-body">
<!-- Pagination -->
<ul class="pagination justify-content-center mb-0">
<li class="page-item {% if page_obj.has_previous %}{% else %}disabled{% endif %}">
<a class="page-link" href="{% if page_obj.has_previous %}?{% if q %}q={{ q }}&{% endif %}page={{ page_obj.previous_page_number }}{% endif %}" aria-label="Previous">
<span aria-hidden="true">&laquo;</span>
<span class="sr-only">Previous</span>
</a>
</li>
{% for l in page_obj.paginator.page_range %}
{% if l <= page_obj.number|add:5 and l >= page_obj.number|add:-5 %}
<li class="page-item {% if forloop.counter == page_obj.number %}active{% endif %}"><a class="page-link" href="?{% if q %}q={{ q }}&{% endif %}page={{ forloop.counter }}">{{ forloop.counter }}</a></li>
{% endif %}
{% endfor %}
<li class="page-item {% if page_obj.has_next %}{% else %}disabled{% endif %}">
<a class="page-link" href="{% if page_obj.has_next %}?{% if q %}q={{ q }}&{% endif %}page={{ page_obj.next_page_number }}{% endif %}" aria-label="Next">
<span aria-hidden="true">&raquo;</span>
<span class="sr-only">Next</span>
</a>
</li>
</ul>
</div>
</div>
</div>
</div>
{% endif %}

View file

@ -4,44 +4,56 @@
Login
{% endblock %}
{% block content %}
{% block body %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">Login</h1>
<hr />
{% if user.is_authenticated %}
<p>You are already logged in!</p>
{% else %}
{% if form.has_errors %}
<p>Your username and password didn't match. Please try again.</p>
{% endif %}
<form method="post" action=".">
{% csrf_token %}
<div class="form-group">
<label for="id_username">Username:</label>
{{ form.username | addclass:"form-control" }}
</div>
<div class="form-group">
<label for="id_password">Password:</label>
{{ form.password | addclass:"form-control" }}
</div>
<div class="form-group">
<input class="form-control" type="submit" value="Login" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Login</h1>
<hr />
{% include 'website/messages.html' %}
{% if user.is_authenticated %}
<div class="alert alert-info" role="alert">You are already logged in!</div>
{% else %}
{% if form.errors %}
<div class="alert alert-danger" role="alert">Your username and password are incorrect. Please try again.</div>
{% endif %}
{% endif %}
{% if not user.is_authenticated %}
<form method="post" action=".">
{% csrf_token %}
<div class="form-group">
<label for="id_username">Username:</label>
{{ form.username | addclass:"form-control" }}
</div>
<div class="form-group">
<label for="id_password">Password:</label>
{{ form.password | addclass:"form-control" }}
</div>
<hr />
<div class="row">
<div class="col-lg-6 col-sm-12 text-center small"><a href="{% url 'password_reset' %}">Forgot Password?</a></div>
<div class="col-lg-6 col-sm-12 text-center small"><a href="{% url 'register' %}">Create Account</a></div>
</div>
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Login" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block titleblock %}
Password Changed
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Password Changed</h1>
<hr />
<p>Your password was changed.</p>
<p>Click <a href="{% url 'index' %}">here</a> to return to the index.</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,50 @@
{% extends "base.html" %}
{% block titleblock %}
Password Change
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col-lg-6 offset-lg-3 col-sm-12">
<div class="card">
<div class="card-body">
<h1 class="card-title">Password Change</h1>
<hr />
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
{% endfor %}
{% endif %}
<form method="post" action="?">
{% csrf_token %}
{% for field in form %}
<div class="form-field my-3">
{{ field.label_tag }}
{{ field | addclass:"form-control" }}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
{% endif %}
</div>
{% endfor %}
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Submit" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block titleblock %}
Forgot Password - Reset Successful
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Password Reset</h1>
<hr />
{% if user.is_authenticated %}
<div class="alert alert-info" role="alert">You are already logged in!</div>
{% else %}
<p>Your password has been successfully reset!</p>
<p>You may now log in using it <a href="{% url 'login' %}">here.</a></p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block titleblock %}
Forgot Password - Reset
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Reset Password</h1>
<hr />
{% if not validlink %}
<div class="alert alert-danger" role="alert">The password reset link has expired. Please request another to proceed.</div>
{% else %}
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
{% endfor %}
{% endif %}
<form method="post" action=".">
{% csrf_token %}
<div class="form-group">
<label for="id_username">Enter new password:</label>
{{ form.new_password1 | addclass:"form-control" }}
</div>
<div class="form-group">
<label for="id_username">Confirm password:</label>
{{ form.new_password2 | addclass:"form-control" }}
</div>
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Login" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block titleblock %}
Forgot Password - Reset Link Sent
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Reset Sent</h1>
<hr />
{% if user.is_authenticated %}
<div class="alert alert-info" role="alert">You are already logged in!</div>
{% else %}
<p>Instructions for resetting your password will be emailed to the
address you provided, if that address matches the one we have on file
for your account. You should receive them shortly.</p>
<p>Please allow up to to a few hours for the email to transmit, and be
sure to check your spam folder if it doesn't show up in a timely manner.</p>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% autoescape off %}
To initiate the password reset process for your {{ user.get_username }} {{ site_name }} account,
click the link below:
{{ protocol }}://{{ domain }}{% url 'password_reset_confirm' uidb64=uid token=token %}
If clicking the link above doesn't work, please copy and paste the URL in a new browser
window instead.
If you did not request a password reset, please disregard this notice. Whoever requested it
cannot follow through on resetting your password without access to this message.
Sincerely,
{{ site_name }} Management.
{% endautoescape %}

View file

@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block titleblock %}
Forgot Password
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Forgot Password</h1>
<hr />
{% if user.is_authenticated %}
<div class="alert alert-info" role="alert">You are already logged in!</div>
{% else %}
{% if form.errors %}
<div class="alert alert-danger" role="alert">The email address provided is incorrect.</div>
{% endif %}
{% endif %}
{% if not user.is_authenticated %}
<form method="post" action=".">
{% csrf_token %}
<div class="form-group">
<label for="id_username">Email address:</label>
{{ form.email | addclass:"form-control" }}
<small id="emailHelp" class="form-text text-muted">The email address you provided at registration. If you left it blank, your password cannot be reset through this form.</small>
</div>
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Login" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% block titleblock %}
Register
{% endblock %}
{% block body %}
{% load addclass %}
<div class="container main-content mt-4" id="main-copy">
<div class="row">
<div class="col-lg-5 offset-lg-3 col-sm-12">
<div class="card mt-3">
<div class="card-body">
<h1 class="card-title">Register</h1>
<hr />
{% if user.is_authenticated %}
<div class="alert alert-info" role="alert">You are already registered!</div>
{% else %}
{% if form.errors %}
{% for field in form %}
{% for error in field.errors %}
<div class="alert alert-danger" role="alert">{{ error }}</div>
{% endfor %}
{% endfor %}
{% endif %}
{% endif %}
{% if not user.is_authenticated %}
<form method="post" action="?">
{% csrf_token %}
{% for field in form %}
<div class="form-field my-3">
{{ field.label_tag }}
{{ field | addclass:"form-control" }}
{% if field.help_text %}
<small class="form-text text-muted">{{ field.help_text|safe }}</small>
{% endif %}
</div>
{% endfor %}
<hr />
<div class="form-group">
<input class="form-control btn btn-outline-secondary" type="submit" value="Register" />
<input type="hidden" name="next" value="{{ next }}" />
</div>
</form>
{% endif %}
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,245 @@
from django.conf import settings
from django.utils.text import slugify
from django.test import Client, override_settings
from django.urls import reverse
from evennia.utils.test_resources import EvenniaTest
class EvenniaWebTest(EvenniaTest):
# Use the same classes the views are expecting
account_typeclass = settings.BASE_ACCOUNT_TYPECLASS
object_typeclass = settings.BASE_OBJECT_TYPECLASS
character_typeclass = settings.BASE_CHARACTER_TYPECLASS
exit_typeclass = settings.BASE_EXIT_TYPECLASS
room_typeclass = settings.BASE_ROOM_TYPECLASS
script_typeclass = settings.BASE_SCRIPT_TYPECLASS
# Default named url
url_name = 'index'
# Response to expect for unauthenticated requests
unauthenticated_response = 200
# Response to expect for authenticated requests
authenticated_response = 200
def setUp(self):
super(EvenniaWebTest, self).setUp()
# Add chars to account rosters
self.account.db._playable_characters = [self.char1]
self.account2.db._playable_characters = [self.char2]
for account in (self.account, self.account2):
# Demote accounts to Player permissions
account.permissions.add('Player')
account.permissions.remove('Developer')
# Grant permissions to chars
for char in account.db._playable_characters:
char.locks.add('edit:id(%s) or perm(Admin)' % account.pk)
char.locks.add('delete:id(%s) or perm(Admin)' % account.pk)
char.locks.add('view:all()')
def test_valid_chars(self):
"Make sure account has playable characters"
self.assertTrue(self.char1 in self.account.db._playable_characters)
self.assertTrue(self.char2 in self.account2.db._playable_characters)
def get_kwargs(self):
return {}
def test_get(self):
# Try accessing page while not logged in
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()))
self.assertEqual(response.status_code, self.unauthenticated_response)
def login(self):
return self.client.login(username='TestAccount', password='testpassword')
def test_get_authenticated(self):
logged_in = self.login()
self.assertTrue(logged_in, 'Account failed to log in!')
# Try accessing page while logged in
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
self.assertEqual(response.status_code, self.authenticated_response)
# ------------------------------------------------------------------------------
class AdminTest(EvenniaWebTest):
url_name = 'django_admin'
unauthenticated_response = 302
class IndexTest(EvenniaWebTest):
url_name = 'index'
class RegisterTest(EvenniaWebTest):
url_name = 'register'
unauthenticated_response = 302
class LoginTest(EvenniaWebTest):
url_name = 'login'
class LogoutTest(EvenniaWebTest):
url_name = 'logout'
class PasswordResetTest(EvenniaWebTest):
url_name = 'password_change'
unauthenticated_response = 302
class WebclientTest(EvenniaWebTest):
url_name = 'webclient:index'
class CharacterCreateView(EvenniaWebTest):
url_name = 'character-create'
unauthenticated_response = 302
@override_settings(MULTISESSION_MODE=0)
def test_valid_access_multisession_0(self):
"Account1 with no characters should be able to create a new one"
self.account.db._playable_characters = []
# Login account
self.login()
# Post data for a new character
data = {
'db_key': 'gannon',
'desc': 'Some dude.'
}
response = self.client.post(reverse(self.url_name), data=data, follow=True)
self.assertEqual(response.status_code, 200)
# Make sure the character was actually created
self.assertTrue(len(self.account.db._playable_characters) == 1, 'Account only has the following characters attributed to it: %s' % self.account.db._playable_characters)
@override_settings(MULTISESSION_MODE=2)
@override_settings(MAX_NR_CHARACTERS=10)
def test_valid_access_multisession_2(self):
"Account1 should be able to create a new character"
# Login account
self.login()
# Post data for a new character
data = {
'db_key': 'gannon',
'desc': 'Some dude.'
}
response = self.client.post(reverse(self.url_name), data=data, follow=True)
self.assertEqual(response.status_code, 200)
# Make sure the character was actually created
self.assertTrue(len(self.account.db._playable_characters) > 1, 'Account only has the following characters attributed to it: %s' % self.account.db._playable_characters)
class CharacterPuppetView(EvenniaWebTest):
url_name = 'character-puppet'
unauthenticated_response = 302
def get_kwargs(self):
return {
'pk': self.char1.pk,
'slug': slugify(self.char1.name)
}
def test_invalid_access(self):
"Account1 should not be able to puppet Account2:Char2"
# Login account
self.login()
# Try to access puppet page for char2
kwargs = {
'pk': self.char2.pk,
'slug': slugify(self.char2.name)
}
response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True)
self.assertTrue(response.status_code >= 400, "Invalid access should return a 4xx code-- either obj not found or permission denied! (Returned %s)" % response.status_code)
class CharacterManageView(EvenniaWebTest):
url_name = 'character-manage'
unauthenticated_response = 302
class CharacterUpdateView(EvenniaWebTest):
url_name = 'character-update'
unauthenticated_response = 302
def get_kwargs(self):
return {
'pk': self.char1.pk,
'slug': slugify(self.char1.name)
}
def test_valid_access(self):
"Account1 should be able to update Account1:Char1"
# Login account
self.login()
# Try to access update page for char1
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
self.assertEqual(response.status_code, 200)
# Try to update char1 desc
data = {'db_key': self.char1.db_key, 'desc': "Just a regular type of dude."}
response = self.client.post(reverse(self.url_name, kwargs=self.get_kwargs()), data=data, follow=True)
self.assertEqual(response.status_code, 200)
# Make sure the change was made successfully
self.assertEqual(self.char1.db.desc, data['desc'])
def test_invalid_access(self):
"Account1 should not be able to update Account2:Char2"
# Login account
self.login()
# Try to access update page for char2
kwargs = {
'pk': self.char2.pk,
'slug': slugify(self.char2.name)
}
response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True)
self.assertEqual(response.status_code, 403)
class CharacterDeleteView(EvenniaWebTest):
url_name = 'character-delete'
unauthenticated_response = 302
def get_kwargs(self):
return {
'pk': self.char1.pk,
'slug': slugify(self.char1.name)
}
def test_valid_access(self):
"Account1 should be able to delete Account1:Char1"
# Login account
self.login()
# Try to access delete page for char1
response = self.client.get(reverse(self.url_name, kwargs=self.get_kwargs()), follow=True)
self.assertEqual(response.status_code, 200)
# Proceed with deleting it
data = {'value': 'yes'}
response = self.client.post(reverse(self.url_name, kwargs=self.get_kwargs()), data=data, follow=True)
self.assertEqual(response.status_code, 200)
# Make sure it deleted
self.assertFalse(self.char1 in self.account.db._playable_characters, 'Char1 is still in Account playable characters list.')
def test_invalid_access(self):
"Account1 should not be able to delete Account2:Char2"
# Login account
self.login()
# Try to access delete page for char2
kwargs = {
'pk': self.char2.pk,
'slug': slugify(self.char2.name)
}
response = self.client.get(reverse(self.url_name, kwargs=kwargs), follow=True)
self.assertEqual(response.status_code, 403)

View file

@ -9,12 +9,20 @@ from django import views as django_views
from evennia.web.website import views as website_views
urlpatterns = [
url(r'^$', website_views.page_index, name="index"),
url(r'^$', website_views.EvenniaIndexView.as_view(), name="index"),
url(r'^tbi/', website_views.to_be_implemented, name='to_be_implemented'),
# User Authentication (makes login/logout url names available)
url(r'^authenticate/', include('django.contrib.auth.urls')),
url(r'^auth/', include('django.contrib.auth.urls')),
url(r'^auth/register', website_views.AccountCreateView.as_view(), name="register"),
# Character management
url(r'^characters/create/$', website_views.CharacterCreateView.as_view(), name="character-create"),
url(r'^characters/manage/$', website_views.CharacterManageView.as_view(), name="character-manage"),
url(r'^characters/puppet/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', website_views.CharacterPuppetView.as_view(), name="character-puppet"),
url(r'^characters/update/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', website_views.CharacterUpdateView.as_view(), name="character-update"),
url(r'^characters/delete/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/$', website_views.CharacterDeleteView.as_view(), name="character-delete"),
# Django original admin page. Make this URL is always available, whether
# we've chosen to use Evennia's custom admin or not.
url(r'django_admin/', website_views.admin_wrapper, name="django_admin"),

View file

@ -7,16 +7,27 @@ templates on the fly.
"""
from django.contrib.admin.sites import site
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import authenticate
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.admin.views.decorators import staff_member_required
from django.core.exceptions import PermissionDenied
from django.db.models.functions import Lower
from django.http import HttpResponseBadRequest, HttpResponseRedirect, Http404
from django.shortcuts import render
from django.urls import reverse, reverse_lazy
from django.views.generic import View, TemplateView, ListView, DetailView, FormView
from django.views.generic.base import RedirectView
from django.views.generic.edit import CreateView, UpdateView, DeleteView
from evennia import SESSION_HANDLER
from evennia.objects.models import ObjectDB
from evennia.accounts.models import AccountDB
from evennia.utils import logger
from evennia.utils import class_from_module, logger
from evennia.web.website.forms import *
from django.contrib.auth import login
from django.utils.text import slugify
_BASE_CHAR_TYPECLASS = settings.BASE_CHARACTER_TYPECLASS
@ -92,20 +103,6 @@ def _gamestats():
return pagevars
def page_index(request):
"""
Main root page.
"""
# handle webclient-website shared login
_shared_login(request)
# get game db stats
pagevars = _gamestats()
return render(request, 'index.html', pagevars)
def to_be_implemented(request):
"""
A notice letting the user know that this particular feature hasn't been
@ -134,3 +131,267 @@ def admin_wrapper(request):
Wrapper that allows us to properly use the base Django admin site, if needed.
"""
return staff_member_required(site.index)(request)
#
# Class-based views
#
class EvenniaIndexView(TemplateView):
# Display this HTML page
template_name = 'website/index.html'
# Display these variables on it
def get_context_data(self, **kwargs):
# Call the base implementation first to get a context object
context = super(EvenniaIndexView, self).get_context_data(**kwargs)
# Add game statistics and other pagevars
context.update(_gamestats())
return context
class EvenniaCreateView(CreateView):
@property
def page_title(self):
return 'Create %s' % self.model._meta.verbose_name.title()
class EvenniaUpdateView(UpdateView):
@property
def page_title(self):
return 'Update %s' % self.model._meta.verbose_name.title()
class EvenniaDeleteView(DeleteView):
@property
def page_title(self):
return 'Delete %s' % self.model._meta.verbose_name.title()
#
# Object views
#
class ObjectDetailView(DetailView):
model = class_from_module(settings.BASE_OBJECT_TYPECLASS)
access_type = 'view'
def get_object(self, queryset=None):
"""
Override of Django hook.
Evennia does not natively store slugs, so where a slug is provided,
calculate the same for the object and make sure it matches.
"""
if not queryset:
queryset = self.get_queryset()
# Get the object, ignoring all checks and filters
obj = self.model.objects.get(pk=self.kwargs.get('pk'))
# Check if this object was requested in a valid manner
if slugify(obj.name) != self.kwargs.get(self.slug_url_kwarg):
raise HttpResponseBadRequest(u"No %(verbose_name)s found matching the query" %
{'verbose_name': queryset.model._meta.verbose_name})
# Check if account has permissions to access object
account = self.request.user
if not obj.access(account, self.access_type):
raise PermissionDenied(u"You are not authorized to %s this object." % self.access_type)
# Get the object, based on the specified queryset
obj = super(ObjectDetailView, self).get_object(queryset)
return obj
class ObjectCreateView(LoginRequiredMixin, EvenniaCreateView):
model = class_from_module(settings.BASE_OBJECT_TYPECLASS)
class ObjectDeleteView(LoginRequiredMixin, ObjectDetailView, EvenniaDeleteView):
model = class_from_module(settings.BASE_OBJECT_TYPECLASS)
access_type = 'delete'
template_name = 'website/object_confirm_delete.html'
def delete(self, request, *args, **kwargs):
"""
Calls the delete() method on the fetched object and then
redirects to the success URL.
We extend this so we can capture the name for the sake of confirmation.
"""
obj = str(self.get_object())
response = super(ObjectDeleteView, self).delete(request, *args, **kwargs)
messages.success(request, "Successfully deleted '%s'." % obj)
return response
class ObjectUpdateView(LoginRequiredMixin, ObjectDetailView, EvenniaUpdateView):
model = class_from_module(settings.BASE_OBJECT_TYPECLASS)
access_type = 'edit'
def get_success_url(self):
if self.success_url: return self.success_url
return self.object.web_get_detail_url()
def get_initial(self):
"""
Override of Django hook.
Prepopulates form field values based on object db attributes as well as
model field values.
"""
# Get the object we want to update
obj = self.get_object()
# Get attributes
data = {k:getattr(obj.db, k, '') for k in self.form_class.base_fields}
# Get model fields
data.update({k:getattr(obj, k, '') for k in self.form_class.Meta.fields})
return data
def form_valid(self, form):
"""
Override of Django hook.
Updates object attributes based on values submitted.
This method is only called if all values for the fields submitted
passed form validation, so at this point we can assume the data is
validated and sanitized.
"""
# Get the values submitted after they've been cleaned and validated
data = {k:v for k,v in form.cleaned_data.items() if k not in self.form_class.Meta.fields}
# Update the object attributes
for key, value in data.items():
setattr(self.object.db, key, value)
messages.success(self.request, "Successfully updated '%s' for %s." % (key, self.object))
# Do not return super().form_valid; we don't want to update the model
# instance, just its attributes.
return HttpResponseRedirect(self.get_success_url())
#
# Account views
#
class AccountMixin(object):
model = class_from_module(settings.BASE_ACCOUNT_TYPECLASS)
form_class = AccountForm
class AccountCreateView(AccountMixin, ObjectCreateView):
template_name = 'website/registration/register.html'
success_url = reverse_lazy('login')
def form_valid(self, form):
username = form.cleaned_data['username']
password = form.cleaned_data['password1']
email = form.cleaned_data.get('email', '')
# Create account
account, errs = self.model.create(
username=username,
password=password,
email=email,)
# If unsuccessful, get messages passed to session.msg
if not account:
[messages.error(self.request, err) for err in errs]
return self.form_invalid(form)
messages.success(self.request, "Your account '%s' was successfully created! You may log in using it now." % account.name)
return HttpResponseRedirect(self.success_url)
#
# Character views
#
class CharacterMixin(object):
model = class_from_module(settings.BASE_CHARACTER_TYPECLASS)
form_class = CharacterForm
success_url = reverse_lazy('character-manage')
def get_queryset(self):
# Get IDs of characters owned by account
ids = [getattr(x, 'id') for x in self.request.user.characters if x]
# Return a queryset consisting of those characters
return self.model.objects.filter(id__in=ids).order_by(Lower('db_key'))
class CharacterPuppetView(LoginRequiredMixin, CharacterMixin, RedirectView, ObjectDetailView):
def get_redirect_url(self, *args, **kwargs):
# Get the requested character, if it belongs to the authenticated user
char = self.get_object()
next = self.request.GET.get('next', self.success_url)
if char:
self.request.session['puppet'] = int(char.pk)
messages.success(self.request, "You become '%s'!" % char)
else:
self.request.session['puppet'] = None
messages.error(self.request, "You cannot become '%s'." % char)
return next
class CharacterManageView(LoginRequiredMixin, CharacterMixin, ListView):
paginate_by = 10
template_name = 'website/character_manage_list.html'
page_title = 'Manage Characters'
class CharacterUpdateView(CharacterMixin, ObjectUpdateView):
form_class = CharacterUpdateForm
template_name = 'website/character_form.html'
class CharacterDeleteView(CharacterMixin, ObjectDeleteView):
pass
class CharacterCreateView(CharacterMixin, ObjectCreateView):
template_name = 'website/character_form.html'
def form_valid(self, form):
# Get account ref
account = self.request.user
character = None
# Get attributes from the form
self.attributes = {k: form.cleaned_data[k] for k in form.cleaned_data.keys()}
charname = self.attributes.pop('db_key')
description = self.attributes.pop('desc')
# Create a character
try:
character, errors = self.model.create(charname, account, description=description)
# Assign attributes from form
[setattr(character.db, key, value) for key,value in self.attributes.items()]
character.db.creator_id = account.id
character.save()
account.save()
except Exception as e:
messages.error(self.request, "There was an error creating your character. If this problem persists, contact an admin.")
logger.log_trace()
return self.form_invalid(form)
if character:
messages.success(self.request, "Your character '%s' was created!" % character.name)
return HttpResponseRedirect(self.success_url)
else:
messages.error(self.request, "Your character could not be created. Please contact an admin.")
return self.form_invalid(form)