From 4b21322477d4c3b11d95b20242d17afb77bfbbc4 Mon Sep 17 00:00:00 2001 From: Johnny Date: Fri, 26 Oct 2018 22:19:02 +0000 Subject: [PATCH] Adds documentation. --- evennia/web/website/forms.py | 102 +++++++++- evennia/web/website/views.py | 363 +++++++++++++++++++++++++++++++---- 2 files changed, 426 insertions(+), 39 deletions(-) diff --git a/evennia/web/website/forms.py b/evennia/web/website/forms.py index 0271e3d432..c9d19cbd8c 100644 --- a/evennia/web/website/forms.py +++ b/evennia/web/website/forms.py @@ -6,8 +6,26 @@ from django.utils.html import escape from evennia.utils import class_from_module class EvenniaForm(forms.Form): + """ + This is a stock Django form, but modified so that all values provided + through it are escaped (sanitized). Validation is performed by the fields + you define in the form. + This has little to do with Evennia itself and is more general web security- + related. + + https://www.owasp.org/index.php/Input_Validation_Cheat_Sheet#Goals_of_Input_Validation + + """ def clean(self): + """ + Django hook. Performed on form submission. + + Returns: + cleaned (dict): Dictionary of key:value pairs submitted on the form. + + """ + # Call parent function cleaned = super(EvenniaForm, self).clean() # Escape all values provided by user @@ -15,30 +33,106 @@ class EvenniaForm(forms.Form): return cleaned class AccountForm(EvenniaForm, UserCreationForm): + """ + This is a generic Django form tailored to the Account model. + In this incarnation it does not allow getting/setting of attributes, only + core User model fields (username, email, password). + + """ class Meta: + """ + This is a Django construct that provides additional configuration to + the form. + + """ + # The model/typeclass this form creates model = class_from_module(settings.BASE_ACCOUNT_TYPECLASS) + + # The fields to display on the form, in the given order fields = ("username", "email") + + # Any overrides of field classes field_classes = {'username': UsernameField} + # Username is collected as part of the core UserCreationForm, so we just need + # to add a field to (optionally) capture email. email = forms.EmailField(help_text="A valid email address. Optional; used for password resets.", required=False) class ObjectForm(EvenniaForm, ModelForm): + """ + This is a Django form for generic Evennia Objects that allows modification + of attributes when called from a descendent of ObjectUpdate or ObjectCreate + views. + It defines no fields by default; you have to do that by extending this class + and defining what fields you want to be recorded. See the CharacterForm for + a simple example of how to do this. + + """ class Meta: + """ + This is a Django construct that provides additional configuration to + the form. + + """ + # The model/typeclass this form creates model = class_from_module(settings.BASE_OBJECT_TYPECLASS) + + # The fields to display on the form, in the given order fields = ("db_key",) + + # This lets us rename ugly db-specific keys to something more human labels = { 'db_key': 'Name', } class CharacterForm(ObjectForm): + """ + This is a Django form for Evennia Character objects. + Since Evennia characters only have one attribute by default, this form only + defines a field for that single attribute. The names of fields you define should + correspond to their names as stored in the dbhandler; you can display + 'prettier' versions of the fieldname on the form using the 'label' kwarg. + + The basic field types are CharFields and IntegerFields, which let you enter + text and numbers respectively. IntegerFields have some neat validation tricks + they can do, like mandating values fall within a certain range. + + For example, a complete "age" field might look like: + + age = forms.IntegerField( + label="Your Age", + min_value=18, max_value=9000, + help_text="Years since your birth.") + + Default input fields are generic text boxes. You can control what sort of + input field users will see by specifying a "widget." An example of this is + used for the 'desc' field to show a Textarea box instead of a Textbox. + + For help in building out your form, please see: + https://docs.djangoproject.com/en/1.11/topics/forms/#building-a-form-in-django + + For more information on fields and their capabilities, see: + https://docs.djangoproject.com/en/1.11/ref/forms/fields/ + + For more on widgets, see: + https://docs.djangoproject.com/en/1.11/ref/forms/widgets/ + + """ class Meta: + """ + This is a Django construct that provides additional configuration to + the form. + + """ # 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', @@ -49,8 +143,12 @@ class CharacterForm(ObjectForm): class CharacterUpdateForm(CharacterForm): """ - Provides a form that only allows updating of db attributes, not model - attributes. + This is a Django form for updating Evennia Character objects. + + By default it is the same as the CharacterForm, but if there are circumstances + in which you don't want to let players edit all the same attributes they had + access to during creation, you can redefine this form with those fields you do + wish to allow. """ pass \ No newline at end of file diff --git a/evennia/web/website/views.py b/evennia/web/website/views.py index f4ea939037..62743583f1 100644 --- a/evennia/web/website/views.py +++ b/evennia/web/website/views.py @@ -139,12 +139,54 @@ def admin_wrapper(request): # class EvenniaIndexView(TemplateView): - # Display this HTML page + """ + 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' - # Display these variables on it + # This method tells the view what data should be displayed on the template. def get_context_data(self, **kwargs): - # Call the base implementation first to get a context object + """ + 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. + + Kwargs: + 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 @@ -152,28 +194,60 @@ class EvenniaIndexView(TemplateView): return context + class EvenniaCreateView(CreateView): + """ + 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.model._meta.verbose_name.title() + class EvenniaDetailView(DetailView): + """ + 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.model._meta.verbose_name.title() + class EvenniaUpdateView(UpdateView): + """ + 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.model._meta.verbose_name.title() + class EvenniaDeleteView(DeleteView): + """ + 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.model._meta.verbose_name.title() # @@ -181,48 +255,96 @@ class EvenniaDeleteView(DeleteView): # 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) - access_type = 'view' + + # 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 context consisting of the attributes - specified at the class level, in the order provided. + 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.model._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. + 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 + # Get the object, ignoring all checks and filters for now obj = self.model.objects.get(pk=self.kwargs.get('pk')) # Check if this object was requested in a valid manner @@ -230,26 +352,47 @@ class ObjectDetailView(EvenniaDetailView): 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 + # Check if the requestor 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 + # 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) + 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) - access_type = 'delete' 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 @@ -257,27 +400,61 @@ class ObjectDeleteView(LoginRequiredMixin, ObjectDetailView, EvenniaDeleteView): 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): +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) + + # -- 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): """ - Override of Django hook. + Django hook, modified for Evennia. - Prepopulates form field values based on object db attributes as well as - model field values. + 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() @@ -296,12 +473,16 @@ class ObjectUpdateView(LoginRequiredMixin, ObjectDetailView, EvenniaUpdateView): 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 values submitted after they've been cleaned and validated + # 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 @@ -318,33 +499,59 @@ class ObjectUpdateView(LoginRequiredMixin, ObjectDetailView, EvenniaUpdateView): # class AccountMixin(object): + """ + 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) form_class = AccountForm + class AccountCreateView(AccountMixin, ObjectCreateView): + """ + 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.model.create( - username=username, + username=username, password=password, email=email,) - # If unsuccessful, get messages passed to session.msg + # 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) # @@ -352,57 +559,140 @@ class AccountCreateView(AccountMixin, ObjectCreateView): # class CharacterMixin(object): + """ + 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) form_class = 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 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): + """ + 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 = 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 + 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 = CharacterUpdateForm template_name = 'website/character_form.html' + class CharacterDetailView(CharacterMixin, ObjectDetailView): - pass + """ + This view provides a mechanism by which a user can view the attributes of + a character, owned by them or not. + + """ + # -- Evennia constructs -- + # What attributes to display for this object + attributes = ['name', 'desc'] + 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): - # Get account ref + """ + 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 @@ -412,23 +702,22 @@ class CharacterCreateView(CharacterMixin, ObjectCreateView): 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) + character, errors = self.model.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: - messages.error(self.request, "Your character could not be created. Please contact an admin.") - return self.form_invalid(form) + # Call the Django "form failed" hook + messages.error(self.request, "Your character could not be created.") + return self.form_invalid(form) \ No newline at end of file