diff --git a/evennia/accounts/accounts.py b/evennia/accounts/accounts.py index 3fed8dc25e6..2a8915a59c8 100644 --- a/evennia/accounts/accounts.py +++ b/evennia/accounts/accounts.py @@ -196,6 +196,19 @@ def nicks(self): @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 @@ def create(cls, *args, **kwargs): 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 diff --git a/evennia/objects/objects.py b/evennia/objects/objects.py index f41dd02d02c..0dca98ef759 100644 --- a/evennia/objects/objects.py +++ b/evennia/objects/objects.py @@ -1944,12 +1944,20 @@ def create(cls, key, account, **kwargs): 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 @@ def create(cls, key, account, **kwargs): # 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) diff --git a/evennia/settings_default.py b/evennia/settings_default.py index 40c36e6f7e3..860e7fbb1a7 100644 --- a/evennia/settings_default.py +++ b/evennia/settings_default.py @@ -755,6 +755,7 @@ '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 @@ 'django.contrib.flatpages', 'django.contrib.sites', 'django.contrib.staticfiles', + 'django.contrib.messages', 'sekizai', 'evennia.utils.idmapper', 'evennia.server', diff --git a/evennia/typeclasses/models.py b/evennia/typeclasses/models.py index 61dddc0c80e..9e27885e016 100644 --- a/evennia/typeclasses/models.py +++ b/evennia/typeclasses/models.py @@ -817,6 +817,38 @@ def web_get_detail_url(self): 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[\w\d\-]+)/(?P[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): """ diff --git a/evennia/web/utils/general_context.py b/evennia/web/utils/general_context.py index 44bc8b3cb3d..8fd52eec2ea 100644 --- a/evennia/web/utils/general_context.py +++ b/evennia/web/utils/general_context.py @@ -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, diff --git a/evennia/web/utils/tests.py b/evennia/web/utils/tests.py index e2b28c35101..093b858f4dd 100644 --- a/evennia/web/utils/tests.py +++ b/evennia/web/utils/tests.py @@ -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'], diff --git a/evennia/web/website/forms.py b/evennia/web/website/forms.py new file mode 100644 index 00000000000..0271e3d432b --- /dev/null +++ b/evennia/web/website/forms.py @@ -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 \ No newline at end of file diff --git a/evennia/web/website/templates/website/_menu.html b/evennia/web/website/templates/website/_menu.html index 8430d0f805a..18fc6ec3a50 100644 --- a/evennia/web/website/templates/website/_menu.html +++ b/evennia/web/website/templates/website/_menu.html @@ -39,9 +39,28 @@ {% block navbar_right %} {% endblock %} {% block navbar_user %} - {% if user.is_authenticated %} -
  • Log Out @@ -51,7 +70,7 @@ Log In
  • - Register + Register
  • {% endif %} {% endblock %} diff --git a/evennia/web/website/templates/website/base.html b/evennia/web/website/templates/website/base.html index a2172b308f1..d465a17171d 100644 --- a/evennia/web/website/templates/website/base.html +++ b/evennia/web/website/templates/website/base.html @@ -12,7 +12,7 @@ - + @@ -29,6 +29,8 @@ {{game_name}} - {% if flatpage %}{{flatpage.title}}{% else %}{% block titleblock %}{{page_title}}{% endblock %}{% endif %} + {% block body %} + {% include "website/_menu.html" %}
    @@ -40,8 +42,12 @@
    {% endif %} @@ -53,10 +59,12 @@ {% endblock %} + + {% endblock %} - - + + diff --git a/evennia/web/website/templates/website/character_form.html b/evennia/web/website/templates/website/character_form.html new file mode 100644 index 00000000000..20ad71e2617 --- /dev/null +++ b/evennia/web/website/templates/website/character_form.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block titleblock %} +{{ view.page_title }} +{% endblock %} + +{% block content %} + +{% load addclass %} + +
    +
    +
    +
    +

    {{ view.page_title }}

    +
    + + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} + + {% endfor %} + {% endfor %} + {% endif %} + +
    + {% csrf_token %} + + {% for field in form %} +
    + {{ field.label_tag }} + {{ field | addclass:"form-control" }} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
    + {% endfor %} + +
    +
    + + +
    +
    + +
    +
    +
    +
    + +{% endblock %} diff --git a/evennia/web/website/templates/website/character_manage_list.html b/evennia/web/website/templates/website/character_manage_list.html new file mode 100644 index 00000000000..c8845f7165c --- /dev/null +++ b/evennia/web/website/templates/website/character_manage_list.html @@ -0,0 +1,36 @@ +{% extends "base.html" %} + +{% block titleblock %} +{{ view.page_title }} +{% endblock %} + +{% block content %} + +{% load addclass %} +
    +
    +
    +
    +

    {{ view.page_title }}

    +
    + + {% for object in object_list %} +
    + +
    +

    {{ object.db_date_created }} +
    Delete +
    Edit

    +
    {{ object }} {% if object.subtitle %}{{ object.subtitle }}{% endif %}
    +

    {{ object.db.desc }}

    +
    +
    + {% endfor %} + +
    +
    +
    +
    +{% endblock %} + + diff --git a/evennia/web/website/templates/website/evennia_admin.html b/evennia/web/website/templates/website/evennia_admin.html index 8e88d9cc505..80e22e2f2ad 100644 --- a/evennia/web/website/templates/website/evennia_admin.html +++ b/evennia/web/website/templates/website/evennia_admin.html @@ -6,7 +6,7 @@
    -
    +

    Admin

    Welcome to the Evennia Admin Page. Here, you can edit many facets of accounts, characters, and other parts of the game. diff --git a/evennia/web/website/templates/website/generic_form.html b/evennia/web/website/templates/website/generic_form.html new file mode 100644 index 00000000000..091d5f4f207 --- /dev/null +++ b/evennia/web/website/templates/website/generic_form.html @@ -0,0 +1,51 @@ +{% extends "base.html" %} + +{% block titleblock %} +Form +{% endblock %} + +{% block body %} + +{% load addclass %} +

    +
    +
    +
    +
    +

    Form

    +
    + + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} + + {% endfor %} + {% endfor %} + {% endif %} + +
    + {% csrf_token %} + + {% for field in form %} +
    + {{ field.label_tag }} + {{ field | addclass:"form-control" }} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
    + {% endfor %} + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/evennia/web/website/templates/website/messages.html b/evennia/web/website/templates/website/messages.html new file mode 100644 index 00000000000..7b237180eb8 --- /dev/null +++ b/evennia/web/website/templates/website/messages.html @@ -0,0 +1,9 @@ +{% if messages %} + +{% for message in messages %} +
    + {{ message }} +
    +{% endfor %} + +{% endif %} \ No newline at end of file diff --git a/evennia/web/website/templates/website/object_confirm_delete.html b/evennia/web/website/templates/website/object_confirm_delete.html new file mode 100644 index 00000000000..1073b26d0c4 --- /dev/null +++ b/evennia/web/website/templates/website/object_confirm_delete.html @@ -0,0 +1,33 @@ +{% extends "base.html" %} + +{% block titleblock %} +{{ view.page_title }} +{% endblock %} + +{% block body %} + +{% load addclass %} +
    +
    +
    +
    +
    +

    {{ view.page_title }}

    +
    +
    + {% csrf_token %} +

    Are you sure you want to delete "{{ object }}"?

    + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/evennia/web/website/templates/website/object_list.html b/evennia/web/website/templates/website/object_list.html new file mode 100644 index 00000000000..06e513dcbb2 --- /dev/null +++ b/evennia/web/website/templates/website/object_list.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block titleblock %} +List +{% endblock %} + +{% block content %} + +{% load addclass %} +
    +
    +
    +
    +
    +

    List

    +
    + +
      + {% for object in object_list %} +
    • {{ object }}
    • + {% endfor %} +
    + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/evennia/web/website/templates/website/pagination.html b/evennia/web/website/templates/website/pagination.html new file mode 100644 index 00000000000..c89d4ee473e --- /dev/null +++ b/evennia/web/website/templates/website/pagination.html @@ -0,0 +1,32 @@ +{% if page_obj %} +
    +
    +
    +
    + + +
    +
    +
    +
    +{% endif %} \ No newline at end of file diff --git a/evennia/web/website/templates/website/registration/login.html b/evennia/web/website/templates/website/registration/login.html index 8b2213a251e..cf798e8f762 100644 --- a/evennia/web/website/templates/website/registration/login.html +++ b/evennia/web/website/templates/website/registration/login.html @@ -4,44 +4,56 @@ Login {% endblock %} -{% block content %} +{% block body %} {% load addclass %} - -
    -
    -
    -
    -

    Login

    -
    - {% if user.is_authenticated %} -

    You are already logged in!

    - {% else %} - {% if form.has_errors %} -

    Your username and password didn't match. Please try again.

    - {% endif %} - -
    - {% csrf_token %} - -
    - - {{ form.username | addclass:"form-control" }} -
    - -
    - - {{ form.password | addclass:"form-control" }} -
    - -
    - - -
    -
    +
    +
    +
    +
    +
    +

    Login

    +
    + {% include 'website/messages.html' %} + {% if user.is_authenticated %} + + {% else %} + {% if form.errors %} + + {% endif %} + {% endif %} + + {% if not user.is_authenticated %} +
    + {% csrf_token %} + +
    + + {{ form.username | addclass:"form-control" }} +
    + +
    + + {{ form.password | addclass:"form-control" }} +
    + +
    + + +
    +
    + + +
    +
    + + {% endif %} +
    -{% endif %} {% endblock %} diff --git a/evennia/web/website/templates/website/registration/password_change_done.html b/evennia/web/website/templates/website/registration/password_change_done.html new file mode 100644 index 00000000000..8869d8d69c5 --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_change_done.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block titleblock %} +Password Changed +{% endblock %} + +{% block body %} + +{% load addclass %} +
    +
    +
    +
    +
    +

    Password Changed

    +
    + +

    Your password was changed.

    + +

    Click here to return to the index.

    + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/evennia/web/website/templates/website/registration/password_change_form.html b/evennia/web/website/templates/website/registration/password_change_form.html new file mode 100644 index 00000000000..1911b83cf09 --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_change_form.html @@ -0,0 +1,50 @@ +{% extends "base.html" %} + +{% block titleblock %} +Password Change +{% endblock %} + +{% block content %} + +{% load addclass %} +
    +
    +
    +
    +

    Password Change

    +
    + + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} + + {% endfor %} + {% endfor %} + {% endif %} + +
    + {% csrf_token %} + + {% for field in form %} +
    + {{ field.label_tag }} + {{ field | addclass:"form-control" }} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
    + {% endfor %} + +
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +{% endblock %} diff --git a/evennia/web/website/templates/website/registration/password_reset_complete.html b/evennia/web/website/templates/website/registration/password_reset_complete.html new file mode 100644 index 00000000000..697b4bc4ad9 --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_reset_complete.html @@ -0,0 +1,31 @@ +{% extends "base.html" %} + +{% block titleblock %} +Forgot Password - Reset Successful +{% endblock %} + +{% block body %} + +{% load addclass %} +
    +
    +
    +
    +
    +

    Password Reset

    +
    + {% if user.is_authenticated %} + + {% else %} + +

    Your password has been successfully reset!

    + +

    You may now log in using it here.

    + + {% endif %} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/evennia/web/website/templates/website/registration/password_reset_confirm.html b/evennia/web/website/templates/website/registration/password_reset_confirm.html new file mode 100644 index 00000000000..a7bdc683bec --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_reset_confirm.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} + +{% block titleblock %} +Forgot Password - Reset +{% endblock %} + +{% block body %} + +{% load addclass %} +
    +
    +
    +
    +
    +

    Reset Password

    +
    + {% if not validlink %} + + {% else %} + + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} + + {% endfor %} + {% endfor %} + {% endif %} + +
    + {% csrf_token %} + +
    + + {{ form.new_password1 | addclass:"form-control" }} +
    + +
    + + {{ form.new_password2 | addclass:"form-control" }} +
    + +
    +
    + + +
    +
    + + {% endif %} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/evennia/web/website/templates/website/registration/password_reset_done.html b/evennia/web/website/templates/website/registration/password_reset_done.html new file mode 100644 index 00000000000..d248c56d0f8 --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_reset_done.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block titleblock %} +Forgot Password - Reset Link Sent +{% endblock %} + +{% block body %} + +{% load addclass %} +
    +
    +
    +
    +
    +

    Reset Sent

    +
    + {% if user.is_authenticated %} + + {% else %} + +

    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.

    + +

    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.

    + + {% endif %} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/evennia/web/website/templates/website/registration/password_reset_email.html b/evennia/web/website/templates/website/registration/password_reset_email.html new file mode 100644 index 00000000000..28e5a0daa22 --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_reset_email.html @@ -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 %} \ No newline at end of file diff --git a/evennia/web/website/templates/website/registration/password_reset_form.html b/evennia/web/website/templates/website/registration/password_reset_form.html new file mode 100644 index 00000000000..f13c532a585 --- /dev/null +++ b/evennia/web/website/templates/website/registration/password_reset_form.html @@ -0,0 +1,48 @@ +{% extends "base.html" %} + +{% block titleblock %} +Forgot Password +{% endblock %} + +{% block body %} + +{% load addclass %} +
    +
    +
    +
    +
    +

    Forgot Password

    +
    + {% if user.is_authenticated %} + + {% else %} + {% if form.errors %} + + {% endif %} + {% endif %} + + {% if not user.is_authenticated %} +
    + {% csrf_token %} + +
    + + {{ form.email | addclass:"form-control" }} + The email address you provided at registration. If you left it blank, your password cannot be reset through this form. +
    + +
    +
    + + +
    +
    + + {% endif %} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/evennia/web/website/templates/website/registration/register.html b/evennia/web/website/templates/website/registration/register.html new file mode 100644 index 00000000000..5475d922bee --- /dev/null +++ b/evennia/web/website/templates/website/registration/register.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block titleblock %} +Register +{% endblock %} + +{% block body %} + +{% load addclass %} +
    +
    +
    +
    +
    +

    Register

    +
    + {% if user.is_authenticated %} + + {% else %} + {% if form.errors %} + {% for field in form %} + {% for error in field.errors %} + + {% endfor %} + {% endfor %} + {% endif %} + {% endif %} + + {% if not user.is_authenticated %} +
    + {% csrf_token %} + + {% for field in form %} +
    + {{ field.label_tag }} + {{ field | addclass:"form-control" }} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} +
    + {% endfor %} + +
    +
    + + +
    +
    + + {% endif %} +
    +
    +
    +
    +
    +{% endblock %} diff --git a/evennia/web/website/tests.py b/evennia/web/website/tests.py new file mode 100644 index 00000000000..f2b321b6528 --- /dev/null +++ b/evennia/web/website/tests.py @@ -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) + \ No newline at end of file diff --git a/evennia/web/website/urls.py b/evennia/web/website/urls.py index f906b6b142d..9076046dff9 100644 --- a/evennia/web/website/urls.py +++ b/evennia/web/website/urls.py @@ -9,12 +9,20 @@ 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[\w\d\-]+)/(?P[0-9]+)/$', website_views.CharacterPuppetView.as_view(), name="character-puppet"), + url(r'^characters/update/(?P[\w\d\-]+)/(?P[0-9]+)/$', website_views.CharacterUpdateView.as_view(), name="character-update"), + url(r'^characters/delete/(?P[\w\d\-]+)/(?P[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"), diff --git a/evennia/web/website/views.py b/evennia/web/website/views.py index fe93b064268..9a33fd0e6d4 100644 --- a/evennia/web/website/views.py +++ b/evennia/web/website/views.py @@ -7,16 +7,27 @@ """ 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)