├── aiteo
├── templatetags
│ ├── __init__.py
│ └── aiteo_tags.py
├── __init__.py
├── workflow.py
├── signals.py
├── admin.py
├── forms.py
├── templates
│ └── aiteo
│ │ ├── _accepted.html
│ │ ├── _question_vote_badge.html
│ │ └── _response_vote_badge.html
├── urls.py
├── models.py
└── views.py
├── tox.ini
├── MANIFEST.in
├── .travis.yml
├── runtests.py
├── README.rst
├── LICENSE
└── setup.py
/aiteo/templatetags/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tox.ini:
--------------------------------------------------------------------------------
1 | [flake8]
2 | ignore = E265,E501
3 |
--------------------------------------------------------------------------------
/aiteo/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "1.0b1.dev5"
2 |
--------------------------------------------------------------------------------
/aiteo/workflow.py:
--------------------------------------------------------------------------------
1 |
2 | def can_mark_accepted(user, question):
3 | return question.user == user
4 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include README.rst
2 |
3 | recursive-include aiteo/templates *
4 | recursive-include aiteo/fixtures *
5 | recursive-include media *
6 |
--------------------------------------------------------------------------------
/aiteo/signals.py:
--------------------------------------------------------------------------------
1 | import django.dispatch
2 |
3 |
4 | voted = django.dispatch.Signal(providing_args=["vote_obj", "upvote"])
5 | vote_cleared = django.dispatch.Signal(providing_args=["vote_obj", "was_upvote"])
6 |
--------------------------------------------------------------------------------
/aiteo/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from aiteo.models import Question, Response
4 |
5 |
6 | class ResponseInline(admin.StackedInline):
7 | model = Response
8 |
9 |
10 | class QuestionAdmin(admin.ModelAdmin):
11 | inlines = [
12 | ResponseInline,
13 | ]
14 |
15 |
16 | admin.site.register(Question, QuestionAdmin)
17 |
--------------------------------------------------------------------------------
/aiteo/forms.py:
--------------------------------------------------------------------------------
1 | from django import forms
2 |
3 | from aiteo.models import Question, Response
4 |
5 |
6 | class AskQuestionForm(forms.ModelForm):
7 |
8 | class Meta:
9 | model = Question
10 | fields = ["question", "content"]
11 |
12 |
13 | class AddResponseForm(forms.ModelForm):
14 |
15 | class Meta:
16 | model = Response
17 | fields = ["content"]
18 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 |
3 | python:
4 | - 2.7
5 |
6 | env:
7 | - DJANGO=1.4
8 | - DJANGO=1.5
9 | - DJANGO=1.6
10 |
11 | install:
12 | - pip install flake8
13 | - pip install -q Django==$DJANGO
14 | - pip install -q django-nose
15 | - pip install -q coverage
16 | - pip install -q python-coveralls
17 | - pip install -e .
18 |
19 | script:
20 | - flake8 .
21 | - coverage run runtests.py
22 |
23 | after_script:
24 | - coveralls
25 |
--------------------------------------------------------------------------------
/aiteo/templatetags/aiteo_tags.py:
--------------------------------------------------------------------------------
1 | from django import template
2 | from django.conf import settings
3 | from django.utils.importlib import import_module
4 |
5 |
6 | workflow = import_module(getattr(settings, "AITEO_WORKFLOW_MODULE", "aiteo.workflow"))
7 | register = template.Library()
8 |
9 |
10 | @register.filter
11 | def can_accept(user, response):
12 | return workflow.can_mark_accepted(user, response.question)
13 |
14 |
15 | @register.filter
16 | def voted_up(user, obj):
17 | return obj.votes.filter(user=user, upvote=True).exists()
18 |
19 |
20 | @register.filter
21 | def voted_down(user, obj):
22 | return obj.votes.filter(user=user, upvote=False).exists()
23 |
--------------------------------------------------------------------------------
/aiteo/templates/aiteo/_accepted.html:
--------------------------------------------------------------------------------
1 | {% load url from future %}
2 | {% load aiteo_tags %}
3 | {% if request.user|can_accept:response %}
4 |
5 | {% if response.accepted %}
6 |
7 | {% else %}
8 |
9 |
10 |
11 | {% endif %}
12 |
13 | {% else %}
14 | {% if response.accepted %}
15 |
16 |
17 |
18 | {% endif %}
19 | {% endif %}
20 |
--------------------------------------------------------------------------------
/runtests.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import sys
3 |
4 | from django.conf import settings
5 |
6 |
7 | if not settings.configured:
8 | settings.configure(
9 | DATABASES={
10 | "default": {
11 | "ENGINE": "django.db.backends.sqlite3",
12 | }
13 | },
14 | ROOT_URLCONF="dialogos.urls",
15 | INSTALLED_APPS=[
16 | "django.contrib.auth",
17 | "django.contrib.contenttypes",
18 | "django.contrib.sessions",
19 | "aiteo",
20 | ],
21 | )
22 |
23 |
24 | from django_nose import NoseTestSuiteRunner
25 |
26 | test_runner = NoseTestSuiteRunner(verbosity=1)
27 | failures = test_runner.run_tests(["aiteo"])
28 |
29 | if failures:
30 | sys.exit(failures)
31 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | aiteo README
2 | ============
3 |
4 | .. image:: https://img.shields.io/travis/eldarion/aiteo.svg
5 | :target: https://travis-ci.org/eldarion/aiteo
6 |
7 | .. image:: https://img.shields.io/coveralls/eldarion/aiteo.svg
8 | :target: https://coveralls.io/r/eldarion/aiteo
9 |
10 | .. image:: https://img.shields.io/pypi/dm/aiteo.svg
11 | :target: https://pypi.python.org/pypi/aiteo/
12 |
13 | .. image:: https://img.shields.io/pypi/v/aiteo.svg
14 | :target: https://pypi.python.org/pypi/aiteo/
15 |
16 | .. image:: https://img.shields.io/badge/license-BSD-blue.svg
17 | :target: https://pypi.python.org/pypi/aiteo/
18 |
19 |
20 | aiteo is an app for asking questions. Similar to StackOverflow this
21 | app provides functionality for people to ask and answer questions.
22 |
--------------------------------------------------------------------------------
/aiteo/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf.urls import patterns, url
2 |
3 |
4 | urlpatterns = patterns(
5 | "aiteo.views",
6 | url(r"^$", "question_list", name="aiteo_question_list"),
7 | url(r"^ask/$", "question_create", name="aiteo_question_create"),
8 | url(r"^questions/(?P\d+)/$", "question_detail", name="aiteo_question_detail"),
9 | url(r"^questions/(?P\d+)/upvote/$", "question_upvote", name="aiteo_question_upvote"),
10 | url(r"^questions/(?P\d+)/downvote/$", "question_downvote", name="aiteo_question_downvote"),
11 | url(r"^responses/(?P\d+)/upvote/$", "response_upvote", name="aiteo_response_upvote"),
12 | url(r"^responses/(?P\d+)/downvote/$", "response_downvote", name="aiteo_response_downvote"),
13 | url(r"^responses/(?P\d+)/accept/$", "mark_accepted", name="aiteo_mark_accepted"),
14 | )
15 |
--------------------------------------------------------------------------------
/aiteo/templates/aiteo/_question_vote_badge.html:
--------------------------------------------------------------------------------
1 | {% load aiteo_tags %}
2 | {% load url from future %}
3 |
4 |
5 | {% if request.user.is_authenticated %}
6 | {% if request.user|voted_up:question or question.user == request.user %}
7 |
8 | {% else %}
9 |
10 |
11 |
12 | {% endif %}
13 | {% endif %}
14 |
15 |
{{ question.score|default:"0" }}
16 |
17 | {% if request.user.is_authenticated %}
18 | {% if user|voted_down:question or question.user == request.user %}
19 |
20 | {% else %}
21 |
22 |
23 |
24 | {% endif %}
25 | {% endif %}
26 |
27 |
28 |
--------------------------------------------------------------------------------
/aiteo/templates/aiteo/_response_vote_badge.html:
--------------------------------------------------------------------------------
1 | {% load aiteo_tags %}
2 | {% load url from future %}
3 |
4 |
5 | {% if request.user.is_authenticated %}
6 | {% if request.user|voted_up:response or response.user == request.user %}
7 |
8 | {% else %}
9 |
10 |
11 |
12 | {% endif %}
13 | {% endif %}
14 |
15 |
{{ response.score|default:"0" }}
16 |
17 | {% if request.user.is_authenticated %}
18 | {% if user|voted_down:response or response.user == request.user %}
19 |
20 | {% else %}
21 |
22 |
23 |
24 | {% endif %}
25 | {% endif %}
26 |
27 |
28 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2010-2014, Eldarion, Inc.
2 | All rights reserved.
3 |
4 | Redistribution and use in source and binary forms, with or without modification,
5 | are permitted provided that the following conditions are met:
6 |
7 | * Redistributions of source code must retain the above copyright notice,
8 | this list of conditions and the following disclaimer.
9 |
10 | * Redistributions in binary form must reproduce the above copyright notice,
11 | this list of conditions and the following disclaimer in the documentation
12 | and/or other materials provided with the distribution.
13 |
14 | * Neither the name of Eldarion, Inc. nor the names of its contributors may
15 | be used to endorse or promote products derived from this software without
16 | specific prior written permission.
17 |
18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28 |
--------------------------------------------------------------------------------
/aiteo/models.py:
--------------------------------------------------------------------------------
1 | from django.core.urlresolvers import reverse
2 | from django.db import models
3 | from django.utils import timezone
4 |
5 | from django.contrib.auth.models import User
6 |
7 | from aiteo.signals import voted, vote_cleared
8 |
9 |
10 | class TimestampModel(models.Model):
11 | created = models.DateTimeField(default=timezone.now)
12 | modified = models.DateTimeField(default=timezone.now)
13 |
14 | class Meta:
15 | abstract = True
16 |
17 | def save(self, *args, **kwargs):
18 | self.modified = timezone.now()
19 | return super(TimestampModel, self).save(*args, **kwargs)
20 |
21 |
22 | class ScoringModel(TimestampModel):
23 | score = models.IntegerField(editable=False, default=0)
24 | vote_count = models.IntegerField(editable=False, default=0)
25 |
26 | class Meta:
27 | abstract = True
28 |
29 | def vote(self, user, upvote):
30 | vote, created = self.votes.get_or_create(user=user, defaults={"upvote": upvote})
31 | if hasattr(vote, "response"):
32 | vote_obj = vote.response
33 | else:
34 | vote_obj = vote.question
35 | changed = not created and (vote.upvote != upvote)
36 | if changed:
37 | vote.delete()
38 | if changed or created:
39 | self.update_score()
40 | if changed:
41 | vote_cleared.send(sender=vote.__class__, vote_obj=vote_obj, was_upvote=upvote)
42 | if created:
43 | voted.send(sender=vote.__class__, vote_obj=vote_obj, upvote=upvote)
44 |
45 | def update_score(self):
46 | votes = self.votes.count()
47 | upvotes = self.votes.filter(upvote=True).count()
48 | downvotes = votes - upvotes
49 | self.score = upvotes - downvotes
50 | self.vote_count = votes
51 | self.save()
52 |
53 |
54 | class Question(ScoringModel):
55 |
56 | question = models.CharField(max_length=100)
57 | content = models.TextField()
58 | user = models.ForeignKey(User, related_name="questions")
59 |
60 | @property
61 | def accepted_response(self):
62 | try:
63 | response = self.responses.get(accepted=True)
64 | except Response.DoesNotExist:
65 | response = None
66 | return response
67 |
68 | def get_absolute_url(self):
69 | return reverse("aiteo_question_detail", args=[self.pk])
70 |
71 |
72 | class Response(ScoringModel):
73 |
74 | question = models.ForeignKey(Question, related_name="responses")
75 | content = models.TextField()
76 | accepted = models.BooleanField(default=False)
77 | user = models.ForeignKey(User, related_name="responses")
78 |
79 | def accept(self):
80 | # check for another active one and mark it inactive
81 | try:
82 | response = Response.objects.get(question=self.question, accepted=True)
83 | except Response.DoesNotExist:
84 | pass
85 | else:
86 | if self != response:
87 | response.accepted = False
88 | response.save()
89 | self.accepted = True
90 | self.save()
91 |
92 | def get_absolute_url(self):
93 | return "%s#response-%d" % (self.question.get_absolute_url(), self.pk)
94 |
95 |
96 | class QuestionVote(TimestampModel):
97 | question = models.ForeignKey(Question, related_name="votes")
98 | upvote = models.BooleanField(default=True)
99 | user = models.ForeignKey(User, related_name="question_votes")
100 |
101 | class Meta:
102 | unique_together = [("question", "user")]
103 |
104 |
105 | class ResponseVote(TimestampModel):
106 | response = models.ForeignKey(Response, related_name="votes")
107 | upvote = models.BooleanField(default=True)
108 | user = models.ForeignKey(User, related_name="response_votes")
109 |
110 | class Meta:
111 | unique_together = [("response", "user")]
112 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | import codecs
2 | import os
3 | import sys
4 |
5 | from distutils.util import convert_path
6 | from fnmatch import fnmatchcase
7 | from setuptools import setup, find_packages
8 |
9 |
10 | def read(fname):
11 | return codecs.open(os.path.join(os.path.dirname(__file__), fname)).read()
12 |
13 |
14 | # Provided as an attribute, so you can append to these instead
15 | # of replicating them:
16 | standard_exclude = ["*.py", "*.pyc", "*$py.class", "*~", ".*", "*.bak"]
17 | standard_exclude_directories = [
18 | ".*", "CVS", "_darcs", "./build", "./dist", "EGG-INFO", "*.egg-info"
19 | ]
20 |
21 |
22 | # (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
23 | # Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
24 | # Note: you may want to copy this into your setup.py file verbatim, as
25 | # you can't import this from another package, when you don't know if
26 | # that package is installed yet.
27 | def find_package_data(
28 | where=".",
29 | package="",
30 | exclude=standard_exclude,
31 | exclude_directories=standard_exclude_directories,
32 | only_in_packages=True,
33 | show_ignored=False
34 | ):
35 | """
36 | Return a dictionary suitable for use in ``package_data``
37 | in a distutils ``setup.py`` file.
38 |
39 | The dictionary looks like::
40 |
41 | {"package": [files]}
42 |
43 | Where ``files`` is a list of all the files in that package that
44 | don"t match anything in ``exclude``.
45 |
46 | If ``only_in_packages`` is true, then top-level directories that
47 | are not packages won"t be included (but directories under packages
48 | will).
49 |
50 | Directories matching any pattern in ``exclude_directories`` will
51 | be ignored; by default directories with leading ``.``, ``CVS``,
52 | and ``_darcs`` will be ignored.
53 |
54 | If ``show_ignored`` is true, then all the files that aren"t
55 | included in package data are shown on stderr (for debugging
56 | purposes).
57 |
58 | Note patterns use wildcards, or can be exact paths (including
59 | leading ``./``), and all searching is case-insensitive.
60 | """
61 | out = {}
62 | stack = [(convert_path(where), "", package, only_in_packages)]
63 | while stack:
64 | where, prefix, package, only_in_packages = stack.pop(0)
65 | for name in os.listdir(where):
66 | fn = os.path.join(where, name)
67 | if os.path.isdir(fn):
68 | bad_name = False
69 | for pattern in exclude_directories:
70 | if fnmatchcase(name, pattern) or fn.lower() == pattern.lower():
71 | bad_name = True
72 | if show_ignored:
73 | print >> sys.stderr, (
74 | "Directory %s ignored by pattern %s"
75 | % (fn, pattern))
76 | break
77 | if bad_name:
78 | continue
79 | if os.path.isfile(os.path.join(fn, "__init__.py")) and not prefix:
80 | if not package:
81 | new_package = name
82 | else:
83 | new_package = package + "." + name
84 | stack.append((fn, "", new_package, False))
85 | else:
86 | stack.append((fn, prefix + name + "/", package, only_in_packages))
87 | elif package or not only_in_packages:
88 | # is a file
89 | bad_name = False
90 | for pattern in exclude:
91 | if fnmatchcase(name, pattern) or fn.lower() == pattern.lower():
92 | bad_name = True
93 | if show_ignored:
94 | print >> sys.stderr, (
95 | "File %s ignored by pattern %s"
96 | % (fn, pattern))
97 | break
98 | if bad_name:
99 | continue
100 | out.setdefault(package, []).append(prefix + name)
101 | return out
102 |
103 |
104 | PACKAGE = "aiteo"
105 | NAME = "aiteo"
106 | DESCRIPTION = "a Django app for question and answer functionality"
107 | AUTHOR = "Eldarion"
108 | AUTHOR_EMAIL = "development@eldarion.com"
109 | URL = "http://github.com/eldarion/aiteo"
110 | VERSION = __import__(PACKAGE).__version__
111 |
112 |
113 | setup(
114 | name=NAME,
115 | version=VERSION,
116 | description=DESCRIPTION,
117 | long_description=read("README.rst"),
118 | author=AUTHOR,
119 | author_email=AUTHOR_EMAIL,
120 | license="BSD",
121 | url=URL,
122 | packages=find_packages(exclude=["tests.*", "tests"]),
123 | package_data=find_package_data(PACKAGE, only_in_packages=False),
124 | classifiers=[
125 | "Development Status :: 3 - Alpha",
126 | "Environment :: Web Environment",
127 | "Intended Audience :: Developers",
128 | "License :: OSI Approved :: BSD License",
129 | "Operating System :: OS Independent",
130 | "Programming Language :: Python",
131 | "Framework :: Django",
132 | ],
133 | zip_safe=False,
134 | )
135 |
--------------------------------------------------------------------------------
/aiteo/views.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | from django.conf import settings
4 | from django.http import HttpResponse, HttpResponseRedirect, HttpResponseForbidden
5 | from django.shortcuts import get_object_or_404, render
6 | from django.template import RequestContext
7 | from django.template.loader import render_to_string
8 | from django.utils.importlib import import_module
9 | from django.views.decorators.http import require_POST
10 |
11 | from account.decorators import login_required
12 | from aiteo.forms import AskQuestionForm, AddResponseForm
13 | from aiteo.models import Question, Response
14 |
15 |
16 | workflow = import_module(getattr(settings, "AITEO_WORKFLOW_MODULE", "aiteo.workflow"))
17 |
18 |
19 | def question_list(request):
20 | questions = Question.objects.all().order_by("-score", "created", "id")
21 | ctx = {
22 | "questions": questions,
23 | }
24 | return render(request, "aiteo/question_list.html", ctx)
25 |
26 |
27 | @login_required
28 | def question_create(request):
29 | if request.method == "POST":
30 | form = AskQuestionForm(request.POST)
31 | if form.is_valid():
32 | question = form.save(commit=False)
33 | question.user = request.user
34 | question.save()
35 | return HttpResponseRedirect(question.get_absolute_url())
36 | else:
37 | form = AskQuestionForm()
38 | ctx = {
39 | "form": form,
40 | }
41 | return render(request, "aiteo/question_create.html", ctx)
42 |
43 |
44 | def question_detail(request, pk):
45 | questions = Question.objects.all()
46 | question = get_object_or_404(questions, pk=pk)
47 | responses = question.responses.order_by("-score", "created", "id")
48 | is_me = question.user == request.user
49 | if request.method == "POST":
50 | add_response_form = AddResponseForm(request.POST)
51 | if add_response_form.is_valid():
52 | response = add_response_form.save(commit=False)
53 | response.question = question
54 | response.user = request.user
55 | response.save()
56 | return HttpResponseRedirect(response.get_absolute_url())
57 | else:
58 | if not is_me or request.user.is_staff:
59 | add_response_form = AddResponseForm()
60 | else:
61 | add_response_form = None
62 | ctx = {
63 | "can_mark_accepted": workflow.can_mark_accepted(request.user, question),
64 | "question": question,
65 | "responses": responses,
66 | "add_response_form": add_response_form,
67 | }
68 | return render(request, "aiteo/question_detail.html", ctx)
69 |
70 |
71 | @login_required
72 | @require_POST
73 | def mark_accepted(request, pk):
74 | response = get_object_or_404(Response, pk=pk)
75 | if not workflow.can_mark_accepted(request.user, response.question):
76 | return HttpResponseForbidden("You are not allowed to mark this question accepted.")
77 |
78 | response.accept()
79 |
80 | data = {"fragments": {}}
81 | for resp in response.question.responses.all():
82 | data["fragments"]["#accepted-{}".format(resp.pk)] = render_to_string(
83 | "aiteo/_accepted.html",
84 | {"response": resp},
85 | context_instance=RequestContext(request)
86 | )
87 | return HttpResponse(json.dumps(data), mimetype="application/json")
88 |
89 |
90 | @login_required
91 | @require_POST
92 | def question_upvote(request, pk):
93 | question = get_object_or_404(Question, pk=pk)
94 | question.vote(user=request.user, upvote=True)
95 | data = {
96 | "html": render_to_string("aiteo/_question_vote_badge.html", {
97 | "question": question
98 | }, context_instance=RequestContext(request))
99 | }
100 | return HttpResponse(json.dumps(data), mimetype="application/json")
101 |
102 |
103 | @login_required
104 | @require_POST
105 | def question_downvote(request, pk):
106 | question = get_object_or_404(Question, pk=pk)
107 | question.vote(user=request.user, upvote=False)
108 | data = {
109 | "html": render_to_string("aiteo/_question_vote_badge.html", {
110 | "question": question
111 | }, context_instance=RequestContext(request))
112 | }
113 | return HttpResponse(json.dumps(data), mimetype="application/json")
114 |
115 |
116 | @login_required
117 | @require_POST
118 | def response_upvote(request, pk):
119 | response = get_object_or_404(Response, pk=pk)
120 | response.vote(user=request.user, upvote=True)
121 | data = {
122 | "html": render_to_string("aiteo/_response_vote_badge.html", {
123 | "response": response
124 | }, context_instance=RequestContext(request))
125 | }
126 | return HttpResponse(json.dumps(data), mimetype="application/json")
127 |
128 |
129 | @login_required
130 | @require_POST
131 | def response_downvote(request, pk):
132 | response = get_object_or_404(Response, pk=pk)
133 | response.vote(user=request.user, upvote=False)
134 | data = {
135 | "html": render_to_string("aiteo/_response_vote_badge.html", {
136 | "response": response
137 | }, context_instance=RequestContext(request))
138 | }
139 | return HttpResponse(json.dumps(data), mimetype="application/json")
140 |
--------------------------------------------------------------------------------