From 273cc311463cdb1d1ff76f970a1a0477cd1ae040 Mon Sep 17 00:00:00 2001 From: Griatch Date: Sun, 16 May 2021 21:09:38 +0200 Subject: [PATCH] Move and split views --- evennia/help/filehelp.py | 5 + evennia/help/models.py | 2 + evennia/web/__init__.py | 7 +- evennia/web/templates/website/_menu.html | 4 +- evennia/web/templates/website/base.html | 2 +- .../web/templates/website/help_detail.html | 2 +- evennia/web/templates/website/help_list.html | 2 +- evennia/web/templates/website/index.html | 13 +- evennia/web/templatetags/README.md | 10 + .../website => }/templatetags/__init__.py | 0 .../website => }/templatetags/addclass.py | 0 evennia/web/website/forms.py | 2 +- evennia/web/website/urls.py | 86 +- evennia/web/website/views.py | 1132 ----------------- evennia/web/website/views/__init__.py | 4 + evennia/web/website/views/accounts.py | 76 ++ evennia/web/website/views/admin.py | 24 + evennia/web/website/views/channels.py | 175 +++ evennia/web/website/views/characters.py | 254 ++++ evennia/web/website/views/errors.py | 16 + evennia/web/website/views/help.py | 162 +++ evennia/web/website/views/index.py | 116 ++ evennia/web/website/views/mixins.py | 91 ++ evennia/web/website/views/objects.py | 268 ++++ evennia/web/website/views/views.py | 42 + 25 files changed, 1299 insertions(+), 1196 deletions(-) create mode 100644 evennia/web/templatetags/README.md rename evennia/web/{templates/website => }/templatetags/__init__.py (100%) rename evennia/web/{templates/website => }/templatetags/addclass.py (100%) delete mode 100644 evennia/web/website/views.py create mode 100644 evennia/web/website/views/__init__.py create mode 100644 evennia/web/website/views/accounts.py create mode 100644 evennia/web/website/views/admin.py create mode 100644 evennia/web/website/views/channels.py create mode 100644 evennia/web/website/views/characters.py create mode 100644 evennia/web/website/views/errors.py create mode 100644 evennia/web/website/views/help.py create mode 100644 evennia/web/website/views/index.py create mode 100644 evennia/web/website/views/mixins.py create mode 100644 evennia/web/website/views/objects.py create mode 100644 evennia/web/website/views/views.py diff --git a/evennia/help/filehelp.py b/evennia/help/filehelp.py index d1c672bb89..3e56f307d6 100644 --- a/evennia/help/filehelp.py +++ b/evennia/help/filehelp.py @@ -99,6 +99,11 @@ class FileHelpEntry: "text": self.entrytext, } + def __str__(self): + return self.key + + def __repr__(self): + return f"" class FileHelpStorageHandler: diff --git a/evennia/help/models.py b/evennia/help/models.py index 1b4289e647..cb4597da5c 100644 --- a/evennia/help/models.py +++ b/evennia/help/models.py @@ -108,6 +108,8 @@ class HelpEntry(SharedMemoryModel): # HelpEntry main class methods # # + def __str__(self): + return str(self.key) def __repr__(self): return f"" diff --git a/evennia/web/__init__.py b/evennia/web/__init__.py index b81aa50936..7b5834ddfb 100644 --- a/evennia/web/__init__.py +++ b/evennia/web/__init__.py @@ -1,6 +1,5 @@ """ -This sub-package holds the web presence of Evennia, using normal -Django to relate the database contents to web pages. Also the basic -webclient and the website are defined in here (the webserver itself is -found under the `server` package). +This sub-package holds the web presence of Evennia, using normal Django to +relate the database contents to web pages. + """ diff --git a/evennia/web/templates/website/_menu.html b/evennia/web/templates/website/_menu.html index 3d6501fb45..13d56f0e5d 100644 --- a/evennia/web/templates/website/_menu.html +++ b/evennia/web/templates/website/_menu.html @@ -27,9 +27,9 @@ folder and edit it to add/remove links to the menu.
  • - About + About
  • -
  • Documentation
  • +
  • Documentation
  • diff --git a/evennia/web/templates/website/base.html b/evennia/web/templates/website/base.html index 1211de036d..820f1351db 100644 --- a/evennia/web/templates/website/base.html +++ b/evennia/web/templates/website/base.html @@ -55,7 +55,7 @@ diff --git a/evennia/web/templates/website/help_detail.html b/evennia/web/templates/website/help_detail.html index e221956c7c..dd2a7d677f 100644 --- a/evennia/web/templates/website/help_detail.html +++ b/evennia/web/templates/website/help_detail.html @@ -16,7 +16,7 @@

    {{ view.page_title }} ({{ object|title }})


    diff --git a/evennia/web/templates/website/help_list.html b/evennia/web/templates/website/help_list.html index d3075cd456..b8264cead6 100644 --- a/evennia/web/templates/website/help_list.html +++ b/evennia/web/templates/website/help_list.html @@ -14,7 +14,7 @@

    {{ view.page_title }}



    diff --git a/evennia/web/templates/website/index.html b/evennia/web/templates/website/index.html index 3c7a99df43..6b35257798 100644 --- a/evennia/web/templates/website/index.html +++ b/evennia/web/templates/website/index.html @@ -15,7 +15,7 @@

    - Welcome to your new installation of Evennia, your friendly + Welcome to your new installation of Evennia, your friendly neighborhood next-generation MUD development system and server.

    @@ -32,12 +32,15 @@ {% endif %}

    For more info, take your time to - peruse our extensive online documentation. + peruse our extensive online documentation.

    Should you have any questions, concerns, bug reports, or - if you want to help out, don't hesitate to join the Evennia community to make your voice heard! Drop a mail to the - mailing list or to come say hi in the developer chatroom. + if you want to help out, don't hesitate to join the Evennia community + to make your voice heard! Drop a mail to the mailing + list or to come say hi in the developer chatroom.

    If you find bugs, please report them to our Issue tracker. @@ -100,7 +103,7 @@

    Evennia

    -

    Evennia is an open-source MUD server built in +

    Evennia is an open-source MUD server built in Python, on top of the Twisted and Django frameworks. This diff --git a/evennia/web/templatetags/README.md b/evennia/web/templatetags/README.md new file mode 100644 index 0000000000..58d928743f --- /dev/null +++ b/evennia/web/templatetags/README.md @@ -0,0 +1,10 @@ +# Template tags + +Template-tags are python code safely callable from inside the html template. + +In a template they are used as + + {% mytagname args %s} + +See the Django documentation on template tags and a lot of tags already coming +with Django. Placing code here allows for custom tags. diff --git a/evennia/web/templates/website/templatetags/__init__.py b/evennia/web/templatetags/__init__.py similarity index 100% rename from evennia/web/templates/website/templatetags/__init__.py rename to evennia/web/templatetags/__init__.py diff --git a/evennia/web/templates/website/templatetags/addclass.py b/evennia/web/templatetags/addclass.py similarity index 100% rename from evennia/web/templates/website/templatetags/addclass.py rename to evennia/web/templatetags/addclass.py diff --git a/evennia/web/website/forms.py b/evennia/web/website/forms.py index 505415f7cd..93af41d0bf 100644 --- a/evennia/web/website/forms.py +++ b/evennia/web/website/forms.py @@ -28,7 +28,7 @@ class EvenniaForm(forms.Form): """ # Call parent function - cleaned = super(EvenniaForm, self).clean() + cleaned = super().clean() # Escape all values provided by user cleaned = {k: escape(v) for k, v in cleaned.items()} diff --git a/evennia/web/website/urls.py b/evennia/web/website/urls.py index 1540a4705a..c2e8b7c8ff 100644 --- a/evennia/web/website/urls.py +++ b/evennia/web/website/urls.py @@ -6,69 +6,57 @@ from django.conf import settings from django.contrib import admin from django.conf.urls import url, include from django import views as django_views -from . import views +from .views import (index, errors, accounts, help as helpviews, channels, + characters, admin as adminviews) urlpatterns = [ - url(r"^$", views.EvenniaIndexView.as_view(), name="index"), - url(r"^tbi/", views.to_be_implemented, name="to_be_implemented"), + # website front page + url(r"^$", index.EvenniaIndexView.as_view(), name="index"), + + # errors + url(r"^tbi/", errors.to_be_implemented, name="to_be_implemented"), # User Authentication (makes login/logout url names available) - url(r"^auth/register", views.AccountCreateView.as_view(), name="register"), + url(r"^auth/register", accounts.AccountCreateView.as_view(), name="register"), url(r"^auth/", include("django.contrib.auth.urls")), # Help Topics - url(r"^help/$", views.HelpListView.as_view(), name="help"), - url( - r"^help/(?P[\w\d\-]+)/(?P[\w\d\-]+)/$", - views.HelpDetailView.as_view(), - name="help-entry-detail", - ), + url(r"^help/$", helpviews.HelpListView.as_view(), name="help"), + url(r"^help/(?P[\w\d\-]+)/(?P[\w\d\-]+)/$", + helpviews.HelpDetailView.as_view(), + name="help-entry-detail"), # Channels - url(r"^channels/$", views.ChannelListView.as_view(), name="channels"), - url( - r"^channels/(?P[\w\d\-]+)/$", - views.ChannelDetailView.as_view(), - name="channel-detail", - ), + url(r"^channels/$", channels.ChannelListView.as_view(), name="channels"), + url(r"^channels/(?P[\w\d\-]+)/$", + channels.ChannelDetailView.as_view(), + name="channel-detail"), # Character management - url(r"^characters/$", views.CharacterListView.as_view(), name="characters"), - url( - r"^characters/create/$", - views.CharacterCreateView.as_view(), - name="character-create", - ), - url( - r"^characters/manage/$", - views.CharacterManageView.as_view(), - name="character-manage", - ), - url( - r"^characters/detail/(?P[\w\d\-]+)/(?P[0-9]+)/$", - views.CharacterDetailView.as_view(), - name="character-detail", - ), - url( - r"^characters/puppet/(?P[\w\d\-]+)/(?P[0-9]+)/$", - views.CharacterPuppetView.as_view(), - name="character-puppet", - ), - url( - r"^characters/update/(?P[\w\d\-]+)/(?P[0-9]+)/$", - views.CharacterUpdateView.as_view(), - name="character-update", - ), - url( - r"^characters/delete/(?P[\w\d\-]+)/(?P[0-9]+)/$", - views.CharacterDeleteView.as_view(), - name="character-delete", - ), + url(r"^characters/$", characters.CharacterListView.as_view(), name="characters"), + url(r"^characters/create/$", + characters.CharacterCreateView.as_view(), + name="character-create"), + url(r"^characters/manage/$", + characters.CharacterManageView.as_view(), + name="character-manage"), + url(r"^characters/detail/(?P[\w\d\-]+)/(?P[0-9]+)/$", + characters.CharacterDetailView.as_view(), + name="character-detail"), + url(r"^characters/puppet/(?P[\w\d\-]+)/(?P[0-9]+)/$", + characters.CharacterPuppetView.as_view(), + name="character-puppet"), + url(r"^characters/update/(?P[\w\d\-]+)/(?P[0-9]+)/$", + characters.CharacterUpdateView.as_view(), + name="character-update"), + url(r"^characters/delete/(?P[\w\d\-]+)/(?P[0-9]+)/$", + characters.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/", views.admin_wrapper, name="django_admin"), + url(r"django_admin/", adminviews.admin_wrapper, name="django_admin"), # Admin docs url(r"^admin/doc/", include("django.contrib.admindocs.urls")), @@ -77,7 +65,7 @@ urlpatterns = [ if settings.EVENNIA_ADMIN: urlpatterns += [ # Our override for the admin. - url("^admin/$", views.evennia_admin, name="evennia_admin"), + url("^admin/$", adminviews.evennia_admin, name="evennia_admin"), # Makes sure that other admin pages get loaded. url(r"^admin/", admin.site.urls), ] diff --git a/evennia/web/website/views.py b/evennia/web/website/views.py deleted file mode 100644 index ec2081b945..0000000000 --- a/evennia/web/website/views.py +++ /dev/null @@ -1,1132 +0,0 @@ -""" -This file contains the generic, assorted views that don't fall under one of the other applications. -Views are django's way of processing e.g. html templates on the fly. - -""" - -from collections import OrderedDict - -from django.contrib.admin.sites import site -from django.conf import settings -from django.contrib import messages -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 -from django.shortcuts import render -from django.urls import reverse_lazy -from django.views.generic import TemplateView, ListView, DetailView -from django.views.generic.base import RedirectView -from django.views.generic.edit import CreateView, UpdateView, DeleteView - -from evennia import SESSION_HANDLER -from evennia.help.models import HelpEntry -from evennia.objects.models import ObjectDB -from evennia.accounts.models import AccountDB -from evennia.utils import class_from_module -from evennia.utils.logger import tail_log_file -from . import forms - -from django.utils.text import slugify - -_BASE_CHAR_TYPECLASS = settings.BASE_CHARACTER_TYPECLASS - -# typeclass fallbacks - -def _gamestats(): - # Some misc. configurable stuff. - # TODO: Move this to either SQL or settings.py based configuration. - fpage_account_limit = 4 - - # A QuerySet of the most recently connected accounts. - recent_users = AccountDB.objects.get_recently_connected_accounts()[:fpage_account_limit] - nplyrs_conn_recent = len(recent_users) or "none" - nplyrs = AccountDB.objects.num_total_accounts() or "none" - nplyrs_reg_recent = len(AccountDB.objects.get_recently_created_accounts()) or "none" - nsess = SESSION_HANDLER.account_count() - # nsess = len(AccountDB.objects.get_connected_accounts()) or "no one" - - nobjs = ObjectDB.objects.count() - nobjs = nobjs or 1 # fix zero-div error with empty database - Character = class_from_module(settings.BASE_CHARACTER_TYPECLASS, - fallback=settings.FALLBACK_CHARACTER_TYPECLASS) - nchars = Character.objects.all_family().count() - Room = class_from_module(settings.BASE_ROOM_TYPECLASS, - fallback=settings.FALLBACK_ROOM_TYPECLASS) - nrooms = Room.objects.all_family().count() - Exit = class_from_module(settings.BASE_EXIT_TYPECLASS, - fallback=settings.FALLBACK_EXIT_TYPECLASS) - nexits = Exit.objects.all_family().count() - nothers = nobjs - nchars - nrooms - nexits - - pagevars = { - "page_title": "Front Page", - "accounts_connected_recent": recent_users, - "num_accounts_connected": nsess or "no one", - "num_accounts_registered": nplyrs or "no", - "num_accounts_connected_recent": nplyrs_conn_recent or "no", - "num_accounts_registered_recent": nplyrs_reg_recent or "no one", - "num_rooms": nrooms or "none", - "num_exits": nexits or "no", - "num_objects": nobjs or "none", - "num_characters": nchars or "no", - "num_others": nothers or "no", - } - return pagevars - - -def to_be_implemented(request): - """ - A notice letting the user know that this particular feature hasn't been - implemented yet. - """ - - pagevars = {"page_title": "To Be Implemented..."} - - return render(request, "tbi.html", pagevars) - - -@staff_member_required -def evennia_admin(request): - """ - Helpful Evennia-specific admin page. - """ - return render(request, "evennia_admin.html", {"accountdb": AccountDB}) - - -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): - """ - This is a basic example of a Django class-based view, which are functionally - very similar to Evennia Commands but differ in structure. Commands are used - to interface with users using a terminal client. Views are used to interface - with users using a web browser. - - To use a class-based view, you need to have written a template in HTML, and - then you write a view like this to tell Django what values to display on it. - - While there are simpler ways of writing views using plain functions (and - Evennia currently contains a few examples of them), just like Commands, - writing views as classes provides you with more flexibility-- you can extend - classes and change things to suit your needs rather than having to copy and - paste entire code blocks over and over. Django also comes with many default - views for displaying things, all of them implemented as classes. - - This particular example displays the index page. - - """ - - # Tell the view what HTML template to use for the page - template_name = "website/index.html" - - # This method tells the view what data should be displayed on the template. - def get_context_data(self, **kwargs): - """ - This is a common Django method. Think of this as the website - equivalent of the Evennia Command.func() method. - - If you just want to display a static page with no customization, you - don't need to define this method-- just create a view, define - template_name and you're done. - - The only catch here is that if you extend or overwrite this method, - you'll always want to make sure you call the parent method to get a - context object. It's just a dict, but it comes prepopulated with all - sorts of background data intended for display on the page. - - You can do whatever you want to it, but it must be returned at the end - of this method. - - Keyword Args: - any (any): Passed through. - - Returns: - context (dict): Dictionary of data you want to display on the page. - - """ - # Always 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 TypeclassMixin: - """ - This is a "mixin", a modifier of sorts. - - Django views typically work with classes called "models." Evennia objects - are an enhancement upon these Django models and are called "typeclasses." - But Django itself has no idea what a "typeclass" is. - - For the sake of mitigating confusion, any view class with this in its - inheritance list will be modified to work with Evennia Typeclass objects or - Django models interchangeably. - - """ - - @property - def typeclass(self): - return self.model - - @typeclass.setter - def typeclass(self, value): - self.model = value - - -class EvenniaCreateView(CreateView, TypeclassMixin): - """ - This view extends Django's default CreateView. - - CreateView is used for creating new objects, be they Accounts, Characters or - otherwise. - - """ - - @property - def page_title(self): - # Makes sure the page has a sensible title. - return "Create %s" % self.typeclass._meta.verbose_name.title() - - -class EvenniaDetailView(DetailView, TypeclassMixin): - """ - This view extends Django's default DetailView. - - DetailView is used for displaying objects, be they Accounts, Characters or - otherwise. - - """ - - @property - def page_title(self): - # Makes sure the page has a sensible title. - return "%s Detail" % self.typeclass._meta.verbose_name.title() - - -class EvenniaUpdateView(UpdateView, TypeclassMixin): - """ - This view extends Django's default UpdateView. - - UpdateView is used for updating objects, be they Accounts, Characters or - otherwise. - - """ - - @property - def page_title(self): - # Makes sure the page has a sensible title. - return "Update %s" % self.typeclass._meta.verbose_name.title() - - -class EvenniaDeleteView(DeleteView, TypeclassMixin): - """ - This view extends Django's default DeleteView. - - DeleteView is used for deleting objects, be they Accounts, Characters or - otherwise. - - """ - - @property - def page_title(self): - # Makes sure the page has a sensible title. - return "Delete %s" % self.typeclass._meta.verbose_name.title() - - -# -# Object views -# - - -class ObjectDetailView(EvenniaDetailView): - """ - This is an important view. - - Any view you write that deals with displaying, updating or deleting a - specific object will want to inherit from this. It provides the mechanisms - by which to retrieve the object and make sure the user requesting it has - permissions to actually *do* things to it. - - """ - - # -- Django constructs -- - # - # Choose what class of object this view will display. Note that this should - # be an actual Python class (i.e. do `from typeclasses.characters import - # Character`, then put `Character`), not an Evennia typeclass path - # (i.e. `typeclasses.characters.Character`). - # - # So when you extend it, this line should look simple, like: - # model = Object - model = class_from_module(settings.BASE_OBJECT_TYPECLASS, - fallback=settings.FALLBACK_OBJECT_TYPECLASS) - - # What HTML template you wish to use to display this page. - template_name = "website/object_detail.html" - - # -- Evennia constructs -- - # - # What lock type to check for the requesting user, authenticated or not. - # https://github.com/evennia/evennia/wiki/Locks#valid-access_types - access_type = "view" - - # What attributes of the object you wish to display on the page. Model-level - # attributes will take precedence over identically-named db.attributes! - # The order you specify here will be followed. - attributes = ["name", "desc"] - - def get_context_data(self, **kwargs): - """ - Adds an 'attributes' list to the request context consisting of the - attributes specified at the class level, and in the order provided. - - Django views do not provide a way to reference dynamic attributes, so - we have to grab them all before we render the template. - - Returns: - context (dict): Django context object - - """ - # Get the base Django context object - context = super(ObjectDetailView, self).get_context_data(**kwargs) - - # Get the object in question - obj = self.get_object() - - # Create an ordered dictionary to contain the attribute map - attribute_list = OrderedDict() - - for attribute in self.attributes: - # Check if the attribute is a core fieldname (name, desc) - if attribute in self.typeclass._meta._property_names: - attribute_list[attribute.title()] = getattr(obj, attribute, "") - - # Check if the attribute is a db attribute (char1.db.favorite_color) - else: - attribute_list[attribute.title()] = getattr(obj.db, attribute, "") - - # Add our attribute map to the Django request context, so it gets - # displayed on the template - context["attribute_list"] = attribute_list - - # Return the comprehensive context object - return context - - def get_object(self, queryset=None): - """ - Override of Django hook that provides some important Evennia-specific - functionality. - - Evennia does not natively store slugs, so where a slug is provided, - calculate the same for the object and make sure it matches. - - This also checks to make sure the user has access to view/edit/delete - this object! - - """ - # A queryset can be provided to pre-emptively limit what objects can - # possibly be returned. For example, you can supply a queryset that - # only returns objects whose name begins with "a". - if not queryset: - queryset = self.get_queryset() - - # Get the object, ignoring all checks and filters for now - obj = self.typeclass.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( - "No %(verbose_name)s found matching the query" - % {"verbose_name": queryset.model._meta.verbose_name} - ) - - # Check if the requestor account has permissions to access object - account = self.request.user - if not obj.access(account, self.access_type): - raise PermissionDenied("You are not authorized to %s this object." % self.access_type) - - # Get the object, if it is in the specified queryset - obj = super(ObjectDetailView, self).get_object(queryset) - - return obj - - -class ObjectCreateView(LoginRequiredMixin, EvenniaCreateView): - """ - This is an important view. - - Any view you write that deals with creating a specific object will want to - inherit from this. It provides the mechanisms by which to make sure the user - requesting creation of an object is authenticated, and provides a sane - default title for the page. - - """ - - model = class_from_module(settings.BASE_OBJECT_TYPECLASS, - fallback=settings.FALLBACK_OBJECT_TYPECLASS) - - -class ObjectDeleteView(LoginRequiredMixin, ObjectDetailView, EvenniaDeleteView): - """ - This is an important view for obvious reasons! - - Any view you write that deals with deleting a specific object will want to - inherit from this. It provides the mechanisms by which to make sure the user - requesting deletion of an object is authenticated, and that they have - permissions to delete the requested object. - - """ - - # -- Django constructs -- - model = class_from_module(settings.BASE_OBJECT_TYPECLASS, - fallback=settings.FALLBACK_OBJECT_TYPECLASS) - template_name = "website/object_confirm_delete.html" - - # -- Evennia constructs -- - access_type = "delete" - - 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. - - """ - # Get the object in question. ObjectDetailView.get_object() will also - # check to make sure the current user (authenticated or not) has - # permission to delete it! - obj = str(self.get_object()) - - # Perform the actual deletion (the parent class handles this, which will - # in turn call the delete() method on the object) - response = super(ObjectDeleteView, self).delete(request, *args, **kwargs) - - # Notify the user of the deletion - messages.success(request, "Successfully deleted '%s'." % obj) - return response - - -class ObjectUpdateView(LoginRequiredMixin, ObjectDetailView, EvenniaUpdateView): - """ - This is an important view. - - Any view you write that deals with updating a specific object will want to - inherit from this. It provides the mechanisms by which to make sure the user - requesting editing of an object is authenticated, and that they have - permissions to edit the requested object. - - This functions slightly different from default Django UpdateViews in that - it does not update core model fields, *only* object attributes! - - """ - - # -- Django constructs -- - model = class_from_module(settings.BASE_OBJECT_TYPECLASS, - fallback=settings.FALLBACK_OBJECT_TYPECLASS) - - # -- Evennia constructs -- - access_type = "edit" - - def get_success_url(self): - """ - Django hook. - - Can be overridden to return any URL you want to redirect the user to - after the object is successfully updated, but by default it goes to the - object detail page so the user can see their changes reflected. - - """ - if self.success_url: - return self.success_url - return self.object.web_get_detail_url() - - def get_initial(self): - """ - Django hook, modified for Evennia. - - Prepopulates the update form field values based on object db attributes. - - Returns: - data (dict): Dictionary of key:value pairs containing initial form - data. - - """ - # 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 is run when the form is submitted and the data on it is deemed - valid-- all values are within expected ranges, all strings contain - valid characters and lengths, etc. - - 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 attributes 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(): - self.object.attributes.add(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(TypeclassMixin): - """ - This is a "mixin", a modifier of sorts. - - Any view class with this in its inheritance list will be modified to work - with Account objects instead of generic Objects or otherwise. - - """ - - # -- Django constructs -- - model = class_from_module(settings.BASE_ACCOUNT_TYPECLASS, - fallback=settings.FALLBACK_ACCOUNT_TYPECLASS) - form_class = forms.AccountForm - - -class AccountCreateView(AccountMixin, EvenniaCreateView): - """ - Account creation view. - - """ - - # -- Django constructs -- - template_name = "website/registration/register.html" - success_url = reverse_lazy("login") - - def form_valid(self, form): - """ - Django hook, modified for Evennia. - - This hook is called after a valid form is submitted. - - When an account creation form is submitted and the data is deemed valid, - proceeds with creating the Account object. - - """ - # Get values provided - username = form.cleaned_data["username"] - password = form.cleaned_data["password1"] - email = form.cleaned_data.get("email", "") - - # Create account - account, errs = self.typeclass.create(username=username, password=password, email=email) - - # If unsuccessful, display error messages to user - if not account: - [messages.error(self.request, err) for err in errs] - - # Call the Django "form failure" hook - return self.form_invalid(form) - - # Inform user of success - messages.success( - self.request, - "Your account '%s' was successfully created! " - "You may log in using it now." % account.name, - ) - - # Redirect the user to the login page - return HttpResponseRedirect(self.success_url) - - -# -# Character views -# - - -class CharacterMixin(TypeclassMixin): - """ - This is a "mixin", a modifier of sorts. - - Any view class with this in its inheritance list will be modified to work - with Character objects instead of generic Objects or otherwise. - - """ - - # -- Django constructs -- - model = class_from_module(settings.BASE_CHARACTER_TYPECLASS, - fallback=settings.FALLBACK_CHARACTER_TYPECLASS) - form_class = forms.CharacterForm - success_url = reverse_lazy("character-manage") - - def get_queryset(self): - """ - This method will override the Django get_queryset method to only - return a list of characters associated with the current authenticated - user. - - Returns: - queryset (QuerySet): Django queryset for use in the given view. - - """ - # Get IDs of characters owned by account - account = self.request.user - ids = [getattr(x, "id") for x in account.characters if x] - - # Return a queryset consisting of those characters - return self.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key")) - - -class CharacterListView(LoginRequiredMixin, CharacterMixin, ListView): - """ - This view provides a mechanism by which a logged-in player can view a list - of all other characters. - - This view requires authentication by default as a nominal effort to prevent - human stalkers and automated bots/scrapers from harvesting data on your users. - - """ - - # -- Django constructs -- - template_name = "website/character_list.html" - paginate_by = 100 - - # -- Evennia constructs -- - page_title = "Character List" - access_type = "view" - - def get_queryset(self): - """ - This method will override the Django get_queryset method to return a - list of all characters (filtered/sorted) instead of just those limited - to the account. - - Returns: - queryset (QuerySet): Django queryset for use in the given view. - - """ - account = self.request.user - - # Return a queryset consisting of characters the user is allowed to - # see. - ids = [ - obj.id for obj in self.typeclass.objects.all() if obj.access(account, self.access_type) - ] - - return self.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key")) - - -class CharacterPuppetView(LoginRequiredMixin, CharacterMixin, RedirectView, ObjectDetailView): - """ - This view provides a mechanism by which a logged-in player can "puppet" one - of their characters within the context of the website. - - It also ensures that any user attempting to puppet something is logged in, - and that their intended puppet is one that they own. - - """ - - def get_redirect_url(self, *args, **kwargs): - """ - Django hook. - - This view returns the URL to which the user should be redirected after - a passed or failed puppet attempt. - - Returns: - url (str): Path to post-puppet destination. - - """ - # Get the requested character, if it belongs to the authenticated user - char = self.get_object() - - # Get the page the user came from - next_page = self.request.GET.get("next", self.success_url) - - if char: - # If the account owns the char, store the ID of the char in the - # Django request's session (different from Evennia session!). - # We do this because characters don't serialize well. - self.request.session["puppet"] = int(char.pk) - messages.success(self.request, "You become '%s'!" % char) - else: - # If the puppeting failed, clear out the cached puppet value - self.request.session["puppet"] = None - messages.error(self.request, "You cannot become '%s'." % char) - - return next_page - - -class CharacterManageView(LoginRequiredMixin, CharacterMixin, ListView): - """ - This view provides a mechanism by which a logged-in player can browse, - edit, or delete their own characters. - - """ - - # -- Django constructs -- - paginate_by = 10 - template_name = "website/character_manage_list.html" - - # -- Evennia constructs -- - page_title = "Manage Characters" - - -class CharacterUpdateView(CharacterMixin, ObjectUpdateView): - """ - This view provides a mechanism by which a logged-in player (enforced by - ObjectUpdateView) can edit the attributes of a character they own. - - """ - - # -- Django constructs -- - form_class = forms.CharacterUpdateForm - template_name = "website/character_form.html" - - -class CharacterDetailView(CharacterMixin, ObjectDetailView): - """ - This view provides a mechanism by which a user can view the attributes of - a character, owned by them or not. - - """ - - # -- Django constructs -- - template_name = "website/object_detail.html" - - # -- Evennia constructs -- - # What attributes to display for this object - attributes = ["name", "desc"] - access_type = "view" - - def get_queryset(self): - """ - This method will override the Django get_queryset method to return a - list of all characters the user may access. - - Returns: - queryset (QuerySet): Django queryset for use in the given view. - - """ - account = self.request.user - - # Return a queryset consisting of characters the user is allowed to - # see. - ids = [ - obj.id for obj in self.typeclass.objects.all() if obj.access(account, self.access_type) - ] - - return self.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key")) - - -class CharacterDeleteView(CharacterMixin, ObjectDeleteView): - """ - This view provides a mechanism by which a logged-in player (enforced by - ObjectDeleteView) can delete a character they own. - - """ - - pass - - -class CharacterCreateView(CharacterMixin, ObjectCreateView): - """ - This view provides a mechanism by which a logged-in player (enforced by - ObjectCreateView) can create a new character. - - """ - - # -- Django constructs -- - template_name = "website/character_form.html" - - def form_valid(self, form): - """ - Django hook, modified for Evennia. - - This hook is called after a valid form is submitted. - - When an character creation form is submitted and the data is deemed valid, - proceeds with creating the Character object. - - """ - # Get account object creating the character - 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 - character, errors = self.typeclass.create(charname, account, description=description) - - if errors: - # Echo error messages to the user - [messages.error(self.request, x) for x in errors] - - if character: - # Assign attributes from form - for key, value in self.attributes.items(): - setattr(character.db, key, value) - - # Return the user to the character management page, unless overridden - messages.success(self.request, "Your character '%s' was created!" % character.name) - return HttpResponseRedirect(self.success_url) - - else: - # Call the Django "form failed" hook - messages.error(self.request, "Your character could not be created.") - return self.form_invalid(form) - - -# -# Channel views -# - - -class ChannelMixin(TypeclassMixin): - """ - This is a "mixin", a modifier of sorts. - - Any view class with this in its inheritance list will be modified to work - with HelpEntry objects instead of generic Objects or otherwise. - - """ - - # -- Django constructs -- - model = class_from_module(settings.BASE_CHANNEL_TYPECLASS, - fallback=settings.FALLBACK_CHANNEL_TYPECLASS) - - # -- Evennia constructs -- - page_title = "Channels" - - # What lock type to check for the requesting user, authenticated or not. - # https://github.com/evennia/evennia/wiki/Locks#valid-access_types - access_type = "listen" - - def get_queryset(self): - """ - Django hook; here we want to return a list of only those Channels - and other documentation that the current user is allowed to see. - - Returns: - queryset (QuerySet): List of Channels available to the user. - - """ - account = self.request.user - - # Get list of all Channels - channels = self.typeclass.objects.all().iterator() - - # Now figure out which ones the current user is allowed to see - bucket = [channel.id for channel in channels if channel.access(account, "listen")] - - # Re-query and set a sorted list - filtered = self.typeclass.objects.filter(id__in=bucket).order_by(Lower("db_key")) - - return filtered - - -class ChannelListView(ChannelMixin, ListView): - """ - Returns a list of channels that can be viewed by a user, authenticated - or not. - - """ - - # -- Django constructs -- - paginate_by = 100 - template_name = "website/channel_list.html" - - # -- Evennia constructs -- - page_title = "Channel Index" - - max_popular = 10 - - def get_context_data(self, **kwargs): - """ - Django hook; we override it to calculate the most popular channels. - - Returns: - context (dict): Django context object - - """ - context = super(ChannelListView, self).get_context_data(**kwargs) - - # Calculate which channels are most popular - context["most_popular"] = sorted( - list(self.get_queryset()), - key=lambda channel: len(channel.subscriptions.all()), - reverse=True, - )[: self.max_popular] - - return context - - -class ChannelDetailView(ChannelMixin, ObjectDetailView): - """ - Returns the log entries for a given channel. - - """ - - # -- Django constructs -- - template_name = "website/channel_detail.html" - - # -- Evennia constructs -- - # What attributes of the object you wish to display on the page. Model-level - # attributes will take precedence over identically-named db.attributes! - # The order you specify here will be followed. - attributes = ["name"] - - # How many log entries to read and display. - max_num_lines = 10000 - - def get_context_data(self, **kwargs): - """ - Django hook; before we can display the channel logs, we need to recall - the logfile and read its lines. - - Returns: - context (dict): Django context object - - """ - # Get the parent context object, necessary first step - context = super(ChannelDetailView, self).get_context_data(**kwargs) - - # Get the filename this Channel is recording to - filename = self.object.attributes.get( - "log_file", default="channel_%s.log" % self.object.key - ) - - # Split log entries so we can filter by time - bucket = [] - for log in (x.strip() for x in tail_log_file(filename, 0, self.max_num_lines)): - if not log: - continue - time, msg = log.split(" [-] ") - time_key = time.split(":")[0] - - bucket.append({"key": time_key, "timestamp": time, "message": msg}) - - # Add the processed entries to the context - context["object_list"] = bucket - - # Get a list of unique timestamps by hour and sort them - context["object_filters"] = sorted(set([x["key"] for x in bucket])) - - return context - - def get_object(self, queryset=None): - """ - Override of Django hook that retrieves an object by slugified channel - name. - - Returns: - channel (Channel): Channel requested in the URL. - - """ - # Get the queryset for the help entries the user can access - if not queryset: - queryset = self.get_queryset() - - # Find the object in the queryset - channel = slugify(self.kwargs.get("slug", "")) - obj = next((x for x in queryset if slugify(x.db_key) == channel), None) - - # Check if this object was requested in a valid manner - if not obj: - raise HttpResponseBadRequest( - "No %(verbose_name)s found matching the query" - % {"verbose_name": queryset.model._meta.verbose_name} - ) - - return obj - - -# -# Help views -# - - -class HelpMixin(TypeclassMixin): - """ - This is a "mixin", a modifier of sorts. - - Any view class with this in its inheritance list will be modified to work - with HelpEntry objects instead of generic Objects or otherwise. - - """ - - # -- Django constructs -- - model = HelpEntry - - # -- Evennia constructs -- - page_title = "Help" - - def get_queryset(self): - """ - Django hook; here we want to return a list of only those HelpEntries - and other documentation that the current user is allowed to see. - - Returns: - queryset (QuerySet): List of Help entries available to the user. - - """ - account = self.request.user - - # Get list of all HelpEntries - entries = self.typeclass.objects.all().iterator() - - # Now figure out which ones the current user is allowed to see - bucket = [entry.id for entry in entries if entry.access(account, "view")] - - # Re-query and set a sorted list - filtered = ( - self.typeclass.objects.filter(id__in=bucket) - .order_by(Lower("db_key")) - .order_by(Lower("db_help_category")) - ) - - return filtered - - -class HelpListView(HelpMixin, ListView): - """ - Returns a list of help entries that can be viewed by a user, authenticated - or not. - - """ - - # -- Django constructs -- - paginate_by = 500 - template_name = "website/help_list.html" - - # -- Evennia constructs -- - page_title = "Help Index" - - -class HelpDetailView(HelpMixin, EvenniaDetailView): - """ - Returns the detail page for a given help entry. - - """ - - # -- Django constructs -- - template_name = "website/help_detail.html" - - def get_context_data(self, **kwargs): - """ - Adds navigational data to the template to let browsers go to the next - or previous entry in the help list. - - Returns: - context (dict): Django context object - - """ - context = super(HelpDetailView, self).get_context_data(**kwargs) - - # Get the object in question - obj = self.get_object() - - # Get queryset and filter out non-related categories - queryset = ( - self.get_queryset() - .filter(db_help_category=obj.db_help_category) - .order_by(Lower("db_key")) - ) - context["topic_list"] = queryset - - # Find the index position of the given obj in the queryset - objs = list(queryset) - for i, x in enumerate(objs): - if obj is x: - break - - # Find the previous and next topics, if either exist - try: - assert i + 1 <= len(objs) and objs[i + 1] is not obj - context["topic_next"] = objs[i + 1] - except: - context["topic_next"] = None - - try: - assert i - 1 >= 0 and objs[i - 1] is not obj - context["topic_previous"] = objs[i - 1] - except: - context["topic_previous"] = None - - # Format the help entry using HTML instead of newlines - text = obj.db_entrytext - text = text.replace("\r\n\r\n", "\n\n") - text = text.replace("\r\n", "\n") - text = text.replace("\n", "
    ") - context["entry_text"] = text - - return context - - def get_object(self, queryset=None): - """ - Override of Django hook that retrieves an object by category and topic - instead of pk and slug. - - Returns: - entry (HelpEntry): HelpEntry requested in the URL. - - """ - # Get the queryset for the help entries the user can access - if not queryset: - queryset = self.get_queryset() - - # Find the object in the queryset - category = slugify(self.kwargs.get("category", "")) - topic = slugify(self.kwargs.get("topic", "")) - obj = next( - ( - x - for x in queryset - if slugify(x.db_help_category) == category and slugify(x.db_key) == topic - ), - None, - ) - - # Check if this object was requested in a valid manner - if not obj: - return HttpResponseBadRequest( - "No %(verbose_name)s found matching the query" - % {"verbose_name": queryset.model._meta.verbose_name} - ) - - return obj diff --git a/evennia/web/website/views/__init__.py b/evennia/web/website/views/__init__.py new file mode 100644 index 0000000000..92e4ffdebe --- /dev/null +++ b/evennia/web/website/views/__init__.py @@ -0,0 +1,4 @@ +""" +Website views. + +""" diff --git a/evennia/web/website/views/accounts.py b/evennia/web/website/views/accounts.py new file mode 100644 index 0000000000..9a81940677 --- /dev/null +++ b/evennia/web/website/views/accounts.py @@ -0,0 +1,76 @@ +""" +Views for managing accounts. + +""" + + +from evennia.utils import class_from_module +from django.conf import settings +from django.contrib import messages +from django.urls import reverse_lazy +from django.http import HttpResponseRedirect + +from .mixins import EvenniaCreateView, TypeclassMixin +from evennia.web.website import forms + + +class AccountMixin(TypeclassMixin): + """ + This is used to grant abilities to classes it is added to. + + Any view class with this in its inheritance list will be modified to work + with Account objects instead of generic Objects or otherwise. + + """ + + # -- Django constructs -- + model = class_from_module(settings.BASE_ACCOUNT_TYPECLASS, + fallback=settings.FALLBACK_ACCOUNT_TYPECLASS) + form_class = forms.AccountForm + + +class AccountCreateView(AccountMixin, EvenniaCreateView): + """ + Account creation view. + + """ + + # -- Django constructs -- + template_name = "website/registration/register.html" + success_url = reverse_lazy("login") + + def form_valid(self, form): + """ + Django hook, modified for Evennia. + + This hook is called after a valid form is submitted. + + When an account creation form is submitted and the data is deemed valid, + proceeds with creating the Account object. + + """ + # Get values provided + username = form.cleaned_data["username"] + password = form.cleaned_data["password1"] + email = form.cleaned_data.get("email", "") + + # Create account + account, errs = self.typeclass.create(username=username, password=password, email=email) + + # If unsuccessful, display error messages to user + if not account: + [messages.error(self.request, err) for err in errs] + + # Call the Django "form failure" hook + return self.form_invalid(form) + + # Inform user of success + messages.success( + self.request, + "Your account '%s' was successfully created! " + "You may log in using it now." % account.name, + ) + + # Redirect the user to the login page + return HttpResponseRedirect(self.success_url) + diff --git a/evennia/web/website/views/admin.py b/evennia/web/website/views/admin.py new file mode 100644 index 0000000000..b5986c3cb6 --- /dev/null +++ b/evennia/web/website/views/admin.py @@ -0,0 +1,24 @@ +""" +Admin views. + +""" + +from django.contrib.admin.sites import site +from evennia.accounts.models import AccountDB +from django.shortcuts import render +from django.contrib.admin.views.decorators import staff_member_required + + +@staff_member_required +def evennia_admin(request): + """ + Helpful Evennia-specific admin page. + """ + return render(request, "evennia_admin.html", {"accountdb": AccountDB}) + + +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) diff --git a/evennia/web/website/views/channels.py b/evennia/web/website/views/channels.py new file mode 100644 index 0000000000..339ed8d9be --- /dev/null +++ b/evennia/web/website/views/channels.py @@ -0,0 +1,175 @@ +""" +Views for managing channels. + +""" + +from django.conf import settings +from django.views.generic import ListView +from django.utils.text import slugify +from django.db.models.functions import Lower +from django.http import HttpResponseBadRequest + +from evennia.utils.logger import tail_log_file +from evennia.utils import class_from_module +from .mixins import TypeclassMixin +from .objects import ObjectDetailView + + +class ChannelMixin(TypeclassMixin): + """ + This is a "mixin", a modifier of sorts. + + Any view class with this in its inheritance list will be modified to work + with HelpEntry objects instead of generic Objects or otherwise. + + """ + + # -- Django constructs -- + model = class_from_module(settings.BASE_CHANNEL_TYPECLASS, + fallback=settings.FALLBACK_CHANNEL_TYPECLASS) + + # -- Evennia constructs -- + page_title = "Channels" + + # What lock type to check for the requesting user, authenticated or not. + # https://github.com/evennia/evennia/wiki/Locks#valid-access_types + access_type = "listen" + + def get_queryset(self): + """ + Django hook; here we want to return a list of only those Channels + and other documentation that the current user is allowed to see. + + Returns: + queryset (QuerySet): List of Channels available to the user. + + """ + account = self.request.user + + # Get list of all Channels + channels = self.typeclass.objects.all().iterator() + + # Now figure out which ones the current user is allowed to see + bucket = [channel.id for channel in channels if channel.access(account, "listen")] + + # Re-query and set a sorted list + filtered = self.typeclass.objects.filter(id__in=bucket).order_by(Lower("db_key")) + + return filtered + + +class ChannelListView(ChannelMixin, ListView): + """ + Returns a list of channels that can be viewed by a user, authenticated + or not. + + """ + + # -- Django constructs -- + paginate_by = 100 + template_name = "website/channel_list.html" + + # -- Evennia constructs -- + page_title = "Channel Index" + + max_popular = 10 + + def get_context_data(self, **kwargs): + """ + Django hook; we override it to calculate the most popular channels. + + Returns: + context (dict): Django context object + + """ + context = super().get_context_data(**kwargs) + + # Calculate which channels are most popular + context["most_popular"] = sorted( + list(self.get_queryset()), + key=lambda channel: len(channel.subscriptions.all()), + reverse=True, + )[: self.max_popular] + + return context + + +class ChannelDetailView(ChannelMixin, ObjectDetailView): + """ + Returns the log entries for a given channel. + + """ + + # -- Django constructs -- + template_name = "website/channel_detail.html" + + # -- Evennia constructs -- + # What attributes of the object you wish to display on the page. Model-level + # attributes will take precedence over identically-named db.attributes! + # The order you specify here will be followed. + attributes = ["name"] + + # How many log entries to read and display. + max_num_lines = 10000 + + def get_context_data(self, **kwargs): + """ + Django hook; before we can display the channel logs, we need to recall + the logfile and read its lines. + + Returns: + context (dict): Django context object + + """ + # Get the parent context object, necessary first step + context = super().get_context_data(**kwargs) + channel = self.object + + # Get the filename this Channel is recording to + filename = channel.get_log_filename() + + # Split log entries so we can filter by time + bucket = [] + for log in (x.strip() for x in tail_log_file(filename, 0, self.max_num_lines)): + if not log: + continue + time, msg = log.split(" [-] ") + time_key = time.split(":")[0] + + bucket.append({"key": time_key, "timestamp": time, "message": msg}) + + # Add the processed entries to the context + context["object_list"] = bucket + + # Get a list of unique timestamps by hour and sort them + context["object_filters"] = sorted(set([x["key"] for x in bucket])) + + return context + + def get_object(self, queryset=None): + """ + Override of Django hook that retrieves an object by slugified channel + name. + + Returns: + channel (Channel): Channel requested in the URL. + + """ + # Get the queryset for the help entries the user can access + if not queryset: + queryset = self.get_queryset() + + # Find the object in the queryset + channel = slugify(self.kwargs.get("slug", "")) + obj = next((x for x in queryset if slugify(x.db_key) == channel), None) + + # Check if this object was requested in a valid manner + if not obj: + raise HttpResponseBadRequest( + "No %(verbose_name)s found matching the query" + % {"verbose_name": queryset.model._meta.verbose_name} + ) + + return obj + + diff --git a/evennia/web/website/views/characters.py b/evennia/web/website/views/characters.py new file mode 100644 index 0000000000..4e57f9efbb --- /dev/null +++ b/evennia/web/website/views/characters.py @@ -0,0 +1,254 @@ +""" +Views for manipulating Characters (children of Objects often used for +puppeting). + +""" + +from django.conf import settings +from django.urls import reverse_lazy +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseRedirect +from django.db.models.functions import Lower +from django.views.generic.base import RedirectView +from django.views.generic import ListView +from evennia.utils import class_from_module +from .mixins import TypeclassMixin +from .objects import ObjectDetailView, ObjectDeleteView, ObjectUpdateView, ObjectCreateView +from evennia.web.website import forms + + +class CharacterMixin(TypeclassMixin): + """ + This is a "mixin", a modifier of sorts. + + Any view class with this in its inheritance list will be modified to work + with Character objects instead of generic Objects or otherwise. + + """ + + # -- Django constructs -- + model = class_from_module(settings.BASE_CHARACTER_TYPECLASS, + fallback=settings.FALLBACK_CHARACTER_TYPECLASS) + form_class = forms.CharacterForm + success_url = reverse_lazy("character-manage") + + def get_queryset(self): + """ + This method will override the Django get_queryset method to only + return a list of characters associated with the current authenticated + user. + + Returns: + queryset (QuerySet): Django queryset for use in the given view. + + """ + # Get IDs of characters owned by account + account = self.request.user + ids = [getattr(x, "id") for x in account.characters if x] + + # Return a queryset consisting of those characters + return self.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key")) + + +class CharacterListView(LoginRequiredMixin, CharacterMixin, ListView): + """ + This view provides a mechanism by which a logged-in player can view a list + of all other characters. + + This view requires authentication by default as a nominal effort to prevent + human stalkers and automated bots/scrapers from harvesting data on your users. + + """ + + # -- Django constructs -- + template_name = "website/character_list.html" + paginate_by = 100 + + # -- Evennia constructs -- + page_title = "Character List" + access_type = "view" + + def get_queryset(self): + """ + This method will override the Django get_queryset method to return a + list of all characters (filtered/sorted) instead of just those limited + to the account. + + Returns: + queryset (QuerySet): Django queryset for use in the given view. + + """ + account = self.request.user + + # Return a queryset consisting of characters the user is allowed to + # see. + ids = [ + obj.id for obj in self.typeclass.objects.all() if obj.access(account, self.access_type) + ] + + return self.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key")) + + +class CharacterPuppetView(LoginRequiredMixin, CharacterMixin, RedirectView, ObjectDetailView): + """ + This view provides a mechanism by which a logged-in player can "puppet" one + of their characters within the context of the website. + + It also ensures that any user attempting to puppet something is logged in, + and that their intended puppet is one that they own. + + """ + + def get_redirect_url(self, *args, **kwargs): + """ + Django hook. + + This view returns the URL to which the user should be redirected after + a passed or failed puppet attempt. + + Returns: + url (str): Path to post-puppet destination. + + """ + # Get the requested character, if it belongs to the authenticated user + char = self.get_object() + + # Get the page the user came from + next_page = self.request.GET.get("next", self.success_url) + + if char: + # If the account owns the char, store the ID of the char in the + # Django request's session (different from Evennia session!). + # We do this because characters don't serialize well. + self.request.session["puppet"] = int(char.pk) + messages.success(self.request, "You become '%s'!" % char) + else: + # If the puppeting failed, clear out the cached puppet value + self.request.session["puppet"] = None + messages.error(self.request, "You cannot become '%s'." % char) + + return next_page + + +class CharacterManageView(LoginRequiredMixin, CharacterMixin, ListView): + """ + This view provides a mechanism by which a logged-in player can browse, + edit, or delete their own characters. + + """ + + # -- Django constructs -- + paginate_by = 10 + template_name = "website/character_manage_list.html" + + # -- Evennia constructs -- + page_title = "Manage Characters" + + +class CharacterUpdateView(CharacterMixin, ObjectUpdateView): + """ + This view provides a mechanism by which a logged-in player (enforced by + ObjectUpdateView) can edit the attributes of a character they own. + + """ + + # -- Django constructs -- + form_class = forms.CharacterUpdateForm + template_name = "website/character_form.html" + + +class CharacterDetailView(CharacterMixin, ObjectDetailView): + """ + This view provides a mechanism by which a user can view the attributes of + a character, owned by them or not. + + """ + + # -- Django constructs -- + template_name = "website/object_detail.html" + + # -- Evennia constructs -- + # What attributes to display for this object + attributes = ["name", "desc"] + access_type = "view" + + def get_queryset(self): + """ + This method will override the Django get_queryset method to return a + list of all characters the user may access. + + Returns: + queryset (QuerySet): Django queryset for use in the given view. + + """ + account = self.request.user + + # Return a queryset consisting of characters the user is allowed to + # see. + ids = [ + obj.id for obj in self.typeclass.objects.all() if obj.access(account, self.access_type) + ] + + return self.typeclass.objects.filter(id__in=ids).order_by(Lower("db_key")) + + +class CharacterDeleteView(CharacterMixin, ObjectDeleteView): + """ + This view provides a mechanism by which a logged-in player (enforced by + ObjectDeleteView) can delete a character they own. + + """ + + pass + + +class CharacterCreateView(CharacterMixin, ObjectCreateView): + """ + This view provides a mechanism by which a logged-in player (enforced by + ObjectCreateView) can create a new character. + + """ + + # -- Django constructs -- + template_name = "website/character_form.html" + + def form_valid(self, form): + """ + Django hook, modified for Evennia. + + This hook is called after a valid form is submitted. + + When an character creation form is submitted and the data is deemed valid, + proceeds with creating the Character object. + + """ + # Get account object creating the character + 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 + character, errors = self.typeclass.create(charname, account, description=description) + + if errors: + # Echo error messages to the user + [messages.error(self.request, x) for x in errors] + + if character: + # Assign attributes from form + for key, value in self.attributes.items(): + setattr(character.db, key, value) + + # Return the user to the character management page, unless overridden + messages.success(self.request, "Your character '%s' was created!" % character.name) + return HttpResponseRedirect(self.success_url) + + else: + # Call the Django "form failed" hook + messages.error(self.request, "Your character could not be created.") + return self.form_invalid(form) + diff --git a/evennia/web/website/views/errors.py b/evennia/web/website/views/errors.py new file mode 100644 index 0000000000..0795308a98 --- /dev/null +++ b/evennia/web/website/views/errors.py @@ -0,0 +1,16 @@ +""" +Error views. + +""" + +from django.shortcuts import render + +def to_be_implemented(request): + """ + A notice letting the user know that this particular feature hasn't been + implemented yet. + """ + + pagevars = {"page_title": "To Be Implemented..."} + + return render(request, "tbi.html", pagevars) diff --git a/evennia/web/website/views/help.py b/evennia/web/website/views/help.py new file mode 100644 index 0000000000..afa439064d --- /dev/null +++ b/evennia/web/website/views/help.py @@ -0,0 +1,162 @@ +""" +Views to manipulate help entries. + +""" + +from django.utils.text import slugify +from django.views.generic import ListView +from django.http import HttpResponseBadRequest +from django.db.models.functions import Lower +from evennia.help.models import HelpEntry +from .mixins import TypeclassMixin, EvenniaDetailView + + +class HelpMixin(TypeclassMixin): + """ + This is a "mixin", a modifier of sorts. + + Any view class with this in its inheritance list will be modified to work + with HelpEntry objects instead of generic Objects or otherwise. + + """ + + # -- Django constructs -- + model = HelpEntry + + # -- Evennia constructs -- + page_title = "Help" + + def get_queryset(self): + """ + Django hook; here we want to return a list of only those HelpEntries + and other documentation that the current user is allowed to see. + + Returns: + queryset (QuerySet): List of Help entries available to the user. + + """ + account = self.request.user + + # Get list of all HelpEntries + entries = self.typeclass.objects.all().iterator() + + # Now figure out which ones the current user is allowed to see + bucket = [entry.id for entry in entries if entry.access(account, "view")] + + # Re-query and set a sorted list + filtered = ( + self.typeclass.objects.filter(id__in=bucket) + .order_by(Lower("db_key")) + .order_by(Lower("db_help_category")) + ) + + return filtered + + +class HelpListView(HelpMixin, ListView): + """ + Returns a list of help entries that can be viewed by a user, authenticated + or not. + + """ + + # -- Django constructs -- + paginate_by = 500 + template_name = "website/help_list.html" + + # -- Evennia constructs -- + page_title = "Help Index" + + +class HelpDetailView(HelpMixin, EvenniaDetailView): + """ + Returns the detail page for a given help entry. + + """ + + # -- Django constructs -- + template_name = "website/help_detail.html" + + def get_context_data(self, **kwargs): + """ + Adds navigational data to the template to let browsers go to the next + or previous entry in the help list. + + Returns: + context (dict): Django context object + + """ + context = super().get_context_data(**kwargs) + + # Get the object in question + obj = self.get_object() + + # Get queryset and filter out non-related categories + queryset = ( + self.get_queryset() + .filter(db_help_category=obj.db_help_category) + .order_by(Lower("db_key")) + ) + context["topic_list"] = queryset + + # Find the index position of the given obj in the queryset + objs = list(queryset) + for i, x in enumerate(objs): + if obj is x: + break + + # Find the previous and next topics, if either exist + try: + assert i + 1 <= len(objs) and objs[i + 1] is not obj + context["topic_next"] = objs[i + 1] + except: + context["topic_next"] = None + + try: + assert i - 1 >= 0 and objs[i - 1] is not obj + context["topic_previous"] = objs[i - 1] + except: + context["topic_previous"] = None + + # Format the help entry using HTML instead of newlines + text = obj.db_entrytext + text = text.replace("\r\n\r\n", "\n\n") + text = text.replace("\r\n", "\n") + text = text.replace("\n", "
    ") + context["entry_text"] = text + + return context + + def get_object(self, queryset=None): + """ + Override of Django hook that retrieves an object by category and topic + instead of pk and slug. + + Returns: + entry (HelpEntry): HelpEntry requested in the URL. + + """ + # Get the queryset for the help entries the user can access + if not queryset: + queryset = self.get_queryset() + + # Find the object in the queryset + category = slugify(self.kwargs.get("category", "")) + topic = slugify(self.kwargs.get("topic", "")) + obj = next( + ( + x + for x in queryset + if slugify(x.db_help_category) == category and slugify(x.db_key) == topic + ), + None, + ) + + # Check if this object was requested in a valid manner + if not obj: + return HttpResponseBadRequest( + "No %(verbose_name)s found matching the query" + % {"verbose_name": queryset.model._meta.verbose_name} + ) + + return obj diff --git a/evennia/web/website/views/index.py b/evennia/web/website/views/index.py new file mode 100644 index 0000000000..7c5c5f1604 --- /dev/null +++ b/evennia/web/website/views/index.py @@ -0,0 +1,116 @@ +""" +The main index page, including the game stats + +""" + +from django.conf import settings +from django.views.generic import TemplateView +from evennia import SESSION_HANDLER +from evennia.objects.models import ObjectDB +from evennia.accounts.models import AccountDB +from evennia.utils import class_from_module + + +def _gamestats(): + """ + Generate a the gamestat context for the main index page + """ + + + # Some misc. configurable stuff. + # TODO: Move this to either SQL or settings.py based configuration. + fpage_account_limit = 4 + + # A QuerySet of the most recently connected accounts. + recent_users = AccountDB.objects.get_recently_connected_accounts()[:fpage_account_limit] + nplyrs_conn_recent = len(recent_users) or "none" + nplyrs = AccountDB.objects.num_total_accounts() or "none" + nplyrs_reg_recent = len(AccountDB.objects.get_recently_created_accounts()) or "none" + nsess = SESSION_HANDLER.account_count() + # nsess = len(AccountDB.objects.get_connected_accounts()) or "no one" + + nobjs = ObjectDB.objects.count() + nobjs = nobjs or 1 # fix zero-div error with empty database + Character = class_from_module(settings.BASE_CHARACTER_TYPECLASS, + fallback=settings.FALLBACK_CHARACTER_TYPECLASS) + nchars = Character.objects.all_family().count() + Room = class_from_module(settings.BASE_ROOM_TYPECLASS, + fallback=settings.FALLBACK_ROOM_TYPECLASS) + nrooms = Room.objects.all_family().count() + Exit = class_from_module(settings.BASE_EXIT_TYPECLASS, + fallback=settings.FALLBACK_EXIT_TYPECLASS) + nexits = Exit.objects.all_family().count() + nothers = nobjs - nchars - nrooms - nexits + + pagevars = { + "page_title": "Front Page", + "accounts_connected_recent": recent_users, + "num_accounts_connected": nsess or "no one", + "num_accounts_registered": nplyrs or "no", + "num_accounts_connected_recent": nplyrs_conn_recent or "no", + "num_accounts_registered_recent": nplyrs_reg_recent or "no one", + "num_rooms": nrooms or "none", + "num_exits": nexits or "no", + "num_objects": nobjs or "none", + "num_characters": nchars or "no", + "num_others": nothers or "no", + } + return pagevars + + +class EvenniaIndexView(TemplateView): + """ + This is a basic example of a Django class-based view, which are functionally + very similar to Evennia Commands but differ in structure. Commands are used + to interface with users using a terminal client. Views are used to interface + with users using a web browser. + + To use a class-based view, you need to have written a template in HTML, and + then you write a view like this to tell Django what values to display on it. + + While there are simpler ways of writing views using plain functions (and + Evennia currently contains a few examples of them), just like Commands, + writing views as classes provides you with more flexibility-- you can extend + classes and change things to suit your needs rather than having to copy and + paste entire code blocks over and over. Django also comes with many default + views for displaying things, all of them implemented as classes. + + This particular example displays the index page. + + """ + + # Tell the view what HTML template to use for the page + template_name = "website/index.html" + + # This method tells the view what data should be displayed on the template. + def get_context_data(self, **kwargs): + """ + This is a common Django method. Think of this as the website + equivalent of the Evennia Command.func() method. + + If you just want to display a static page with no customization, you + don't need to define this method-- just create a view, define + template_name and you're done. + + The only catch here is that if you extend or overwrite this method, + you'll always want to make sure you call the parent method to get a + context object. It's just a dict, but it comes prepopulated with all + sorts of background data intended for display on the page. + + You can do whatever you want to it, but it must be returned at the end + of this method. + + Keyword Args: + any (any): Passed through. + + Returns: + context (dict): Dictionary of data you want to display on the page. + + """ + # Always call the base implementation first to get a context object + context = super().get_context_data(**kwargs) + + # Add game statistics and other pagevars + context.update(_gamestats()) + + return context diff --git a/evennia/web/website/views/mixins.py b/evennia/web/website/views/mixins.py new file mode 100644 index 0000000000..bfad031178 --- /dev/null +++ b/evennia/web/website/views/mixins.py @@ -0,0 +1,91 @@ +""" +These are mixins for class-based views, granting functionality. + +""" +from django.views.generic import DetailView +from django.views.generic.edit import CreateView, UpdateView, DeleteView + + +class TypeclassMixin: + """ + This is a "mixin", a modifier of sorts. + + Django views typically work with classes called "models." Evennia objects + are an enhancement upon these Django models and are called "typeclasses." + But Django itself has no idea what a "typeclass" is. + + For the sake of mitigating confusion, any view class with this in its + inheritance list will be modified to work with Evennia Typeclass objects or + Django models interchangeably. + + """ + + @property + def typeclass(self): + return self.model + + @typeclass.setter + def typeclass(self, value): + self.model = value + + +class EvenniaCreateView(CreateView, TypeclassMixin): + """ + This view extends Django's default CreateView. + + CreateView is used for creating new objects, be they Accounts, Characters or + otherwise. + + """ + + @property + def page_title(self): + # Makes sure the page has a sensible title. + return "Create %s" % self.typeclass._meta.verbose_name.title() + + +class EvenniaDetailView(DetailView, TypeclassMixin): + """ + This view extends Django's default DetailView. + + DetailView is used for displaying objects, be they Accounts, Characters or + otherwise. + + """ + + @property + def page_title(self): + # Makes sure the page has a sensible title. + return "%s Detail" % self.typeclass._meta.verbose_name.title() + + +class EvenniaUpdateView(UpdateView, TypeclassMixin): + """ + This view extends Django's default UpdateView. + + UpdateView is used for updating objects, be they Accounts, Characters or + otherwise. + + """ + + @property + def page_title(self): + # Makes sure the page has a sensible title. + return "Update %s" % self.typeclass._meta.verbose_name.title() + + +class EvenniaDeleteView(DeleteView, TypeclassMixin): + """ + This view extends Django's default DeleteView. + + DeleteView is used for deleting objects, be they Accounts, Characters or + otherwise. + + """ + + @property + def page_title(self): + # Makes sure the page has a sensible title. + return "Delete %s" % self.typeclass._meta.verbose_name.title() + + diff --git a/evennia/web/website/views/objects.py b/evennia/web/website/views/objects.py new file mode 100644 index 0000000000..3520440a6d --- /dev/null +++ b/evennia/web/website/views/objects.py @@ -0,0 +1,268 @@ +""" +Views for managing a specific object) + +""" + +from collections import OrderedDict +from django.conf import settings +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseBadRequest, HttpResponseRedirect +from django.core.exceptions import PermissionDenied +from django.contrib import messages +from evennia.utils import class_from_module +from django.utils.text import slugify +from .mixins import ( + EvenniaCreateView, EvenniaDeleteView, EvenniaUpdateView, EvenniaDetailView) + + +class ObjectDetailView(EvenniaDetailView): + """ + This is an important view. + + Any view you write that deals with displaying, updating or deleting a + specific object will want to inherit from this. It provides the mechanisms + by which to retrieve the object and make sure the user requesting it has + permissions to actually *do* things to it. + + """ + + # -- Django constructs -- + # + # Choose what class of object this view will display. Note that this should + # be an actual Python class (i.e. do `from typeclasses.characters import + # Character`, then put `Character`), not an Evennia typeclass path + # (i.e. `typeclasses.characters.Character`). + # + # So when you extend it, this line should look simple, like: + # model = Object + model = class_from_module(settings.BASE_OBJECT_TYPECLASS, + fallback=settings.FALLBACK_OBJECT_TYPECLASS) + + # What HTML template you wish to use to display this page. + template_name = "website/object_detail.html" + + # -- Evennia constructs -- + # + # What lock type to check for the requesting user, authenticated or not. + # https://github.com/evennia/evennia/wiki/Locks#valid-access_types + access_type = "view" + + # What attributes of the object you wish to display on the page. Model-level + # attributes will take precedence over identically-named db.attributes! + # The order you specify here will be followed. + attributes = ["name", "desc"] + + def get_context_data(self, **kwargs): + """ + Adds an 'attributes' list to the request context consisting of the + attributes specified at the class level, and in the order provided. + + Django views do not provide a way to reference dynamic attributes, so + we have to grab them all before we render the template. + + Returns: + context (dict): Django context object + + """ + # Get the base Django context object + context = super().get_context_data(**kwargs) + + # Get the object in question + obj = self.get_object() + + # Create an ordered dictionary to contain the attribute map + attribute_list = OrderedDict() + + for attribute in self.attributes: + # Check if the attribute is a core fieldname (name, desc) + if attribute in self.typeclass._meta._property_names: + attribute_list[attribute.title()] = getattr(obj, attribute, "") + + # Check if the attribute is a db attribute (char1.db.favorite_color) + else: + attribute_list[attribute.title()] = getattr(obj.db, attribute, "") + + # Add our attribute map to the Django request context, so it gets + # displayed on the template + context["attribute_list"] = attribute_list + + # Return the comprehensive context object + return context + + def get_object(self, queryset=None): + """ + Override of Django hook that provides some important Evennia-specific + functionality. + + Evennia does not natively store slugs, so where a slug is provided, + calculate the same for the object and make sure it matches. + + This also checks to make sure the user has access to view/edit/delete + this object! + + """ + # A queryset can be provided to pre-emptively limit what objects can + # possibly be returned. For example, you can supply a queryset that + # only returns objects whose name begins with "a". + if not queryset: + queryset = self.get_queryset() + + # Get the object, ignoring all checks and filters for now + obj = self.typeclass.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( + "No %(verbose_name)s found matching the query" + % {"verbose_name": queryset.model._meta.verbose_name} + ) + + # Check if the requestor account has permissions to access object + account = self.request.user + if not obj.access(account, self.access_type): + raise PermissionDenied("You are not authorized to %s this object." % self.access_type) + + # Get the object, if it is in the specified queryset + obj = super(ObjectDetailView, self).get_object(queryset) + + return obj + + +class ObjectCreateView(LoginRequiredMixin, EvenniaCreateView): + """ + This is an important view. + + Any view you write that deals with creating a specific object will want to + inherit from this. It provides the mechanisms by which to make sure the user + requesting creation of an object is authenticated, and provides a sane + default title for the page. + + """ + + model = class_from_module(settings.BASE_OBJECT_TYPECLASS, + fallback=settings.FALLBACK_OBJECT_TYPECLASS) + + +class ObjectDeleteView(LoginRequiredMixin, ObjectDetailView, EvenniaDeleteView): + """ + This is an important view for obvious reasons! + + Any view you write that deals with deleting a specific object will want to + inherit from this. It provides the mechanisms by which to make sure the user + requesting deletion of an object is authenticated, and that they have + permissions to delete the requested object. + + """ + + # -- Django constructs -- + model = class_from_module(settings.BASE_OBJECT_TYPECLASS, + fallback=settings.FALLBACK_OBJECT_TYPECLASS) + template_name = "website/object_confirm_delete.html" + + # -- Evennia constructs -- + access_type = "delete" + + 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. + + """ + # Get the object in question. ObjectDetailView.get_object() will also + # check to make sure the current user (authenticated or not) has + # permission to delete it! + obj = str(self.get_object()) + + # Perform the actual deletion (the parent class handles this, which will + # in turn call the delete() method on the object) + response = super().delete(request, *args, **kwargs) + + # Notify the user of the deletion + messages.success(request, "Successfully deleted '%s'." % obj) + return response + + +class ObjectUpdateView(LoginRequiredMixin, ObjectDetailView, EvenniaUpdateView): + """ + This is an important view. + + Any view you write that deals with updating a specific object will want to + inherit from this. It provides the mechanisms by which to make sure the user + requesting editing of an object is authenticated, and that they have + permissions to edit the requested object. + + This functions slightly different from default Django UpdateViews in that + it does not update core model fields, *only* object attributes! + + """ + + # -- Django constructs -- + model = class_from_module(settings.BASE_OBJECT_TYPECLASS, + fallback=settings.FALLBACK_OBJECT_TYPECLASS) + + # -- Evennia constructs -- + access_type = "edit" + + def get_success_url(self): + """ + Django hook. + + Can be overridden to return any URL you want to redirect the user to + after the object is successfully updated, but by default it goes to the + object detail page so the user can see their changes reflected. + + """ + if self.success_url: + return self.success_url + return self.object.web_get_detail_url() + + def get_initial(self): + """ + Django hook, modified for Evennia. + + Prepopulates the update form field values based on object db attributes. + + Returns: + data (dict): Dictionary of key:value pairs containing initial form + data. + + """ + # 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 is run when the form is submitted and the data on it is deemed + valid-- all values are within expected ranges, all strings contain + valid characters and lengths, etc. + + 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 attributes 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(): + self.object.attributes.add(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()) diff --git a/evennia/web/website/views/views.py b/evennia/web/website/views/views.py new file mode 100644 index 0000000000..ed69190a40 --- /dev/null +++ b/evennia/web/website/views/views.py @@ -0,0 +1,42 @@ +""" +This file contains the generic, assorted views that don't fall under one of the other applications. +Views are django's way of processing e.g. html templates on the fly. + +""" + +from collections import OrderedDict + +from django.contrib.admin.sites import site +from django.conf import settings +from django.contrib import messages +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 +from django.shortcuts import render +from django.urls import reverse_lazy +from django.views.generic import TemplateView, ListView, DetailView +from django.views.generic.base import RedirectView +from django.views.generic.edit import CreateView, UpdateView, DeleteView + +from evennia import SESSION_HANDLER +from evennia.help.models import HelpEntry +from evennia.objects.models import ObjectDB +from evennia.accounts.models import AccountDB +from evennia.utils import class_from_module +from evennia.utils.logger import tail_log_file +from . import forms + +from django.utils.text import slugify + +# +# Channel views +# + + +# +# Help views +# + +