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.
"""
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.comms.models import TempMsg, ChannelDB
from evennia.comms.managers import ChannelManager
@ -622,3 +626,151 @@ class DefaultChannel(with_metaclass(TypeclassBase, ChannelDB)):
"""
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">
{% block nabvar_left %}
<li>
<a class="nav-link" href="/">Home</a>
<a class="nav-link" href="{% url 'index' %}">Home</a>
</li>
<li>
<a class="nav-link" href="https://github.com/evennia/evennia/wiki/Evennia-Introduction/">About</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 'admin:index' %}">Admin</a></li>
{% endif %}
<li><a class="nav-link" href="{% url 'channels' %}">Channels</a></li>
<li><a class="nav-link" href="{% url 'help' %}">Help</a></li>
{% if webclient_enabled %}
<li><a class="nav-link" href="{% url 'webclient:index' %}">Play Online</a></li>
{% endif %}
{% if user.is_staff %}
<li><a class="nav-link" href="{% url 'admin:index' %}">Admin</a></li>
{% endif %}
{% endblock %}
</ul>
<ul class="nav navbar-nav ml-auto w-120 justify-content-end">
{% block navbar_right %}
{% endblock %}
{% block navbar_user %}
{% if account %}
<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 %}
<!-- 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 -->
<hr />
{% endif %}

View file

@ -54,7 +54,7 @@
<div class="col-lg-3 col-sm-12">
{% if user.is_staff %}
<!-- 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 -->
<hr />
{% endif %}

View file

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

View file

@ -2,6 +2,7 @@ from django.conf import settings
from django.utils.text import slugify
from django.test import Client, override_settings
from django.urls import reverse
from evennia.utils import class_from_module
from evennia.utils.test_resources import EvenniaTest
class EvenniaWebTest(EvenniaTest):
@ -13,6 +14,7 @@ class EvenniaWebTest(EvenniaTest):
exit_typeclass = settings.BASE_EXIT_TYPECLASS
room_typeclass = settings.BASE_ROOM_TYPECLASS
script_typeclass = settings.BASE_SCRIPT_TYPECLASS
channel_typeclass = settings.BASE_CHANNEL_TYPECLASS
# Default named url
url_name = 'index'
@ -92,6 +94,25 @@ class PasswordResetTest(EvenniaWebTest):
class WebclientTest(EvenniaWebTest):
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):
url_name = 'character-create'
unauthenticated_response = 302

View file

@ -20,6 +20,10 @@ urlpatterns = [
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"),
# 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
url(r'^characters/create/$', website_views.CharacterCreateView.as_view(), name="character-create"),
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.accounts.models import AccountDB
from evennia.utils import class_from_module, logger
from evennia.utils.logger import tail_log_file
from evennia.web.website.forms import *
from django.contrib.auth import login
@ -713,6 +714,168 @@ class CharacterCreateView(CharacterMixin, ObjectCreateView):
messages.error(self.request, "Your character could not be created.")
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
#