Merge pull request #1722 from strikaco/channelsurfing

Adds ChannelViews
This commit is contained in:
Griatch 2018-10-28 21:58:21 +01:00 committed by GitHub
commit cd3af403a7
10 changed files with 529 additions and 7 deletions

View file

@ -2,6 +2,10 @@
Base typeclass for in-game Channels. Base typeclass for in-game Channels.
""" """
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.text import slugify
from evennia.typeclasses.models import TypeclassBase from evennia.typeclasses.models import TypeclassBase
from evennia.comms.models import TempMsg, ChannelDB from evennia.comms.models import TempMsg, ChannelDB
from evennia.comms.managers import ChannelManager from evennia.comms.managers import ChannelManager
@ -622,3 +626,151 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
""" """
pass pass
#
# Web/Django methods
#
def web_get_admin_url(self):
"""
Returns the URI path for the Django Admin page for this object.
ex. Account#1 = '/admin/accounts/accountdb/1/change/'
Returns:
path (str): URI path to Django Admin page for object.
"""
content_type = ContentType.objects.get_for_model(self.__class__)
return reverse("admin:%s_%s_change" % (content_type.app_label,
content_type.model), args=(self.id,))
@classmethod
def web_get_create_url(cls):
"""
Returns the URI path for a View that allows users to create new
instances of this object.
ex. Chargen = '/characters/create/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'channel-create' would be referenced by this method.
ex.
url(r'channels/create/', ChannelCreateView.as_view(), name='channel-create')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can create new objects is the
developer's responsibility.
Returns:
path (str): URI path to object creation page, if defined.
"""
try:
return reverse('%s-create' % slugify(cls._meta.verbose_name))
except:
return '#'
def web_get_detail_url(self):
"""
Returns the URI path for a View that allows users to view details for
this object.
ex. Oscar (Character) = '/characters/oscar/1/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'channel-detail' would be referenced by this method.
ex.
url(r'channels/(?P<slug>[\w\d\-]+)/$',
ChannelDetailView.as_view(), name='channel-detail')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can view this object is the developer's
responsibility.
Returns:
path (str): URI path to object detail page, if defined.
"""
try:
return reverse('%s-detail' % slugify(self._meta.verbose_name),
kwargs={'slug': slugify(self.db_key)})
except:
return '#'
def web_get_update_url(self):
"""
Returns the URI path for a View that allows users to update this
object.
ex. Oscar (Character) = '/characters/oscar/1/change/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'channel-update' would be referenced by this method.
ex.
url(r'channels/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/change/$',
ChannelUpdateView.as_view(), name='channel-update')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can modify objects is the developer's
responsibility.
Returns:
path (str): URI path to object update page, if defined.
"""
try:
return reverse('%s-update' % slugify(self._meta.verbose_name),
kwargs={'slug': slugify(self.db_key)})
except:
return '#'
def web_get_delete_url(self):
"""
Returns the URI path for a View that allows users to delete this object.
ex. Oscar (Character) = '/characters/oscar/1/delete/'
For this to work, the developer must have defined a named view somewhere
in urls.py that follows the format 'modelname-action', so in this case
a named view of 'channel-delete' would be referenced by this method.
ex.
url(r'channels/(?P<slug>[\w\d\-]+)/(?P<pk>[0-9]+)/delete/$',
ChannelDeleteView.as_view(), name='channel-delete')
If no View has been created and defined in urls.py, returns an
HTML anchor.
This method is naive and simply returns a path. Securing access to
the actual view and limiting who can delete this object is the developer's
responsibility.
Returns:
path (str): URI path to object deletion page, if defined.
"""
try:
return reverse('%s-delete' % slugify(self._meta.verbose_name),
kwargs={'slug': slugify(self.db_key)})
except:
return '#'
# Used by Django Sites/Admin
get_absolute_url = web_get_detail_url

View file

@ -23,24 +23,26 @@ folder and edit it to add/remove links to the menu.
<ul class="navbar-nav"> <ul class="navbar-nav">
{% block nabvar_left %} {% block nabvar_left %}
<li> <li>
<a class="nav-link" href="/">Home</a> <a class="nav-link" href="{% url 'index' %}">Home</a>
</li> </li>
<li> <li>
<a class="nav-link" href="https://github.com/evennia/evennia/wiki/Evennia-Introduction/">About</a> <a class="nav-link" href="https://github.com/evennia/evennia/wiki/Evennia-Introduction/">About</a>
</li> </li>
<li><a class="nav-link" href="https://github.com/evennia/evennia/wiki">Documentation</a></li> <li><a class="nav-link" href="https://github.com/evennia/evennia/wiki">Documentation</a></li>
{% if user.is_staff %} <li><a class="nav-link" href="{% url 'channels' %}">Channels</a></li>
<li><a class="nav-link" href="{% url 'admin:index' %}">Admin</a></li>
{% endif %}
<li><a class="nav-link" href="{% url 'help' %}">Help</a></li> <li><a class="nav-link" href="{% url 'help' %}">Help</a></li>
{% if webclient_enabled %} {% if webclient_enabled %}
<li><a class="nav-link" href="{% url 'webclient:index' %}">Play Online</a></li> <li><a class="nav-link" href="{% url 'webclient:index' %}">Play Online</a></li>
{% endif %} {% endif %}
{% if user.is_staff %}
<li><a class="nav-link" href="{% url 'admin:index' %}">Admin</a></li>
{% endif %}
{% endblock %} {% endblock %}
</ul> </ul>
<ul class="nav navbar-nav ml-auto w-120 justify-content-end"> <ul class="nav navbar-nav ml-auto w-120 justify-content-end">
{% block navbar_right %} {% block navbar_right %}
{% endblock %} {% endblock %}
{% block navbar_user %} {% block navbar_user %}
{% if account %} {% if account %}
<li class="nav-item dropdown"> <li class="nav-item dropdown">

View file

@ -0,0 +1,94 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }} ({{ object }})
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }} ({{ object }})</h1>
<hr />
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'channels' %}">Channels</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ object.db_key|title }}</li>
</ol>
<hr />
<div class="row">
<!-- left column -->
<div class="col-lg-9 col-sm-12">
<!-- heading -->
<div class="card border-light">
<div class="card-body">
{% if object.db.desc and object.db.desc != None %}{{ object.db.desc }}{% else %}No description provided.{% endif %}
</div>
</div>
<hr />
<!-- end heading -->
{% if object_list %}
<pre>{% for log in object_list %}
<a id="{{ log.key }}"></a>{{ log.timestamp }}: {{ log.message }}{% endfor %}</pre>
{% else %}
<pre>No recent log entries have been recorded for this channel.</pre>
{% endif %}
</div>
<!-- end left column -->
<!-- right column -->
<div class="col-lg-3 col-sm-12">
{% if request.user.is_staff %}
<!-- admin button -->
<a class="btn btn-info btn-block mb-3" href="{{ object.web_get_admin_url }}">Admin</a>
<!-- end admin button -->
<hr />
{% endif %}
<div class="card mb-3">
<div class="card-body">
<dl>
{% for attribute, value in attribute_list.items %}
<dt>{{ attribute }}</dt>
<dd>{{ value }}</dd>
{% endfor %}
</dl>
<dl>
<dt>Subscriptions</dt>
<dd>{{ object.subscriptions.all|length }}</dd>
</dl>
</div>
</div>
{% if object_filters %}
<div class="card mb-3">
<div class="card-header">Date Index</div>
<ul class="list-group list-group-flush">
{% for filter in object_filters %}
<a href="#{{ filter }}" class="list-group-item">{{ filter }}</a>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<!-- end right column -->
</div>
<hr />
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,86 @@
{% extends "base.html" %}
{% block titleblock %}
{{ view.page_title }}
{% endblock %}
{% block content %}
{% load addclass %}
<div class="row">
<div class="col">
<div class="card">
<div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1>
<hr />
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{% url 'channels' %}">Channels</a></li>
</ol>
<hr />
<div class="row">
<!-- left column -->
<div class="col-lg-9 col-sm-12">
<div class="table-responsive">
<table class="table table-sm">
<thead class="thead-light">
<tr>
<th scope="col">Channel</th>
<th scope="col">Description</th>
<th scope="col">Subscriptions</th>
</tr>
</thead>
<tbody>
{% for object in object_list %}
<tr>
<td><a href="{{ object.web_get_detail_url }}">{{ object.name }}</a></td>
<td>{{ object.db.desc }}</td>
<td>{{ object.subscriptions.all|length }}</td>
</tr>
{% empty %}
<tr>
<td colspan="3">No channels were found!</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- end left column -->
<!-- right column -->
<div class="col-lg-3 col-sm-12">
{% if request.user.is_staff %}
<!-- admin button -->
<a class="btn btn-info btn-block mb-3" href="/admin/comms/channeldb/">Admin</a>
<!-- end admin button -->
<hr />
{% endif %}
{% if most_popular %}
<div class="card mb-3">
<div class="card-header">Most Popular</div>
<ul class="list-group list-group-flush">
{% for top in most_popular %}
<a href="{{ top.web_get_detail_url }}" class="list-group-item">{{ top }} <span class="badge badge-light float-right">{{ top.subscriptions.all|length }}</span></a>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
<!-- end right column -->
</div>
<hr />
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -56,7 +56,7 @@
{% if request.user.is_staff %} {% if request.user.is_staff %}
<!-- admin button --> <!-- admin button -->
<a class="btn btn-info btn-block mb-3" href="{{ object.web_get_admin_url }}">Edit</a> <a class="btn btn-info btn-block mb-3" href="{{ object.web_get_admin_url }}">Admin</a>
<!-- end admin button --> <!-- end admin button -->
<hr /> <hr />
{% endif %} {% endif %}

View file

@ -54,7 +54,7 @@
<div class="col-lg-3 col-sm-12"> <div class="col-lg-3 col-sm-12">
{% if user.is_staff %} {% if user.is_staff %}
<!-- admin button --> <!-- admin button -->
<a class="btn btn-info btn-block mb-3" href="/admin/help/helpentry/add/">Create New</a> <a class="btn btn-info btn-block mb-3" href="/admin/help/helpentry/add/">Admin</a>
<!-- end admin button --> <!-- end admin button -->
<hr /> <hr />
{% endif %} {% endif %}

View file

@ -9,7 +9,7 @@
{% load addclass %} {% load addclass %}
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<div class="card mt-3"> <div class="card">
<div class="card-body"> <div class="card-body">
<h1 class="card-title">{{ view.page_title }}</h1> <h1 class="card-title">{{ view.page_title }}</h1>
<hr /> <hr />

View file

@ -2,6 +2,7 @@ from django.conf import settings
from django.utils.text import slugify from django.utils.text import slugify
from django.test import Client, override_settings from django.test import Client, override_settings
from django.urls import reverse from django.urls import reverse
from evennia.utils import class_from_module
from evennia.utils.test_resources import EvenniaTest from evennia.utils.test_resources import EvenniaTest
class EvenniaWebTest(EvenniaTest): class EvenniaWebTest(EvenniaTest):
@ -13,6 +14,7 @@ class EvenniaWebTest(EvenniaTest):
exit_typeclass = settings.BASE_EXIT_TYPECLASS exit_typeclass = settings.BASE_EXIT_TYPECLASS
room_typeclass = settings.BASE_ROOM_TYPECLASS room_typeclass = settings.BASE_ROOM_TYPECLASS
script_typeclass = settings.BASE_SCRIPT_TYPECLASS script_typeclass = settings.BASE_SCRIPT_TYPECLASS
channel_typeclass = settings.BASE_CHANNEL_TYPECLASS
# Default named url # Default named url
url_name = 'index' url_name = 'index'
@ -92,6 +94,25 @@ class PasswordResetTest(EvenniaWebTest):
class WebclientTest(EvenniaWebTest): class WebclientTest(EvenniaWebTest):
url_name = 'webclient:index' url_name = 'webclient:index'
class ChannelListTest(EvenniaWebTest):
url_name = 'channels'
class ChannelDetailTest(EvenniaWebTest):
url_name = 'channel-detail'
def setUp(self):
super(ChannelDetailTest, self).setUp()
klass = class_from_module(self.channel_typeclass)
# Create a channel
klass.create('demo')
def get_kwargs(self):
return {
'slug': slugify('demo')
}
class CharacterCreateView(EvenniaWebTest): class CharacterCreateView(EvenniaWebTest):
url_name = 'character-create' url_name = 'character-create'
unauthenticated_response = 302 unauthenticated_response = 302

View file

@ -20,6 +20,10 @@ urlpatterns = [
url(r'^help/$', website_views.HelpListView.as_view(), name="help"), url(r'^help/$', website_views.HelpListView.as_view(), name="help"),
url(r'^help/(?P<category>[\w\d\-]+)/(?P<topic>[\w\d\-]+)/$', website_views.HelpDetailView.as_view(), name="help-entry-detail"), url(r'^help/(?P<category>[\w\d\-]+)/(?P<topic>[\w\d\-]+)/$', website_views.HelpDetailView.as_view(), name="help-entry-detail"),
# Channels
url(r'^channels/$', website_views.ChannelListView.as_view(), name="channels"),
url(r'^channels/(?P<slug>[\w\d\-]+)/$', website_views.ChannelDetailView.as_view(), name="channel-detail"),
# Character management # Character management
url(r'^characters/create/$', website_views.CharacterCreateView.as_view(), name="character-create"), url(r'^characters/create/$', website_views.CharacterCreateView.as_view(), name="character-create"),
url(r'^characters/manage/$', website_views.CharacterManageView.as_view(), name="character-manage"), url(r'^characters/manage/$', website_views.CharacterManageView.as_view(), name="character-manage"),

View file

@ -27,6 +27,7 @@ from evennia.help.models import HelpEntry
from evennia.objects.models import ObjectDB from evennia.objects.models import ObjectDB
from evennia.accounts.models import AccountDB from evennia.accounts.models import AccountDB
from evennia.utils import class_from_module, logger from evennia.utils import class_from_module, logger
from evennia.utils.logger import tail_log_file
from evennia.web.website.forms import * from evennia.web.website.forms import *
from django.contrib.auth import login from django.contrib.auth import login
@ -713,6 +714,168 @@ class CharacterCreateView(CharacterMixin, ObjectCreateView):
messages.error(self.request, "Your character could not be created.") messages.error(self.request, "Your character could not be created.")
return self.form_invalid(form) return self.form_invalid(form)
#
# Channel views
#
class ChannelMixin(object):
"""
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)
# -- 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.model.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.model.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(u"No %(verbose_name)s found matching the query" %
{'verbose_name': queryset.model._meta.verbose_name})
return obj
# #
# Help views # Help views
# #