app transaction list page (bug 825262)

This commit is contained in:
Kevin Ngo 2012-12-29 21:55:08 -08:00
Родитель 3ef31ef5e7
Коммит d08c79d4e8
16 изменённых файлов: 468 добавлений и 163 удалений

Просмотреть файл

@ -73,6 +73,13 @@ CONTRIB_TYPES = {
CONTRIB_VOLUNTARY: _('Voluntary'),
}
MKT_TRANSACTION_CONTRIB_TYPES = {
CONTRIB_CHARGEBACK: _('Chargeback'),
CONTRIB_INAPP: _('In-app Purchase'),
CONTRIB_PURCHASE: _('Purchase'),
CONTRIB_REFUND: _('Refund'),
}
CONTRIB_TYPE_DEFAULT = CONTRIB_VOLUNTARY
INAPP_STATUS_ACTIVE = 0

Просмотреть файл

@ -14,7 +14,7 @@ from tower import ugettext as _
import amo
from amo.helpers import absolutify, urlparams
from amo.models import ModelBase, SearchMixin
from amo.models import SearchMixin
from amo.fields import DecimalCharField
from amo.utils import send_mail, send_mail_jinja
from zadmin.models import DownloadSource
@ -430,12 +430,12 @@ class ClientData(models.Model):
else:
lang = translation.get_language()
client_data, c = cls.objects.get_or_create(
download_source=download_source,
device_type=request.POST.get('device_type', ''),
user_agent=request.META.get('HTTP_USER_AGENT', ''),
is_chromeless=request.POST.get('chromeless', False),
language=lang,
region=region)
download_source=download_source,
device_type=request.POST.get('device_type', ''),
user_agent=request.META.get('HTTP_USER_AGENT', ''),
is_chromeless=request.POST.get('chromeless', False),
language=lang,
region=region)
return client_data
class Meta:

Просмотреть файл

@ -5,7 +5,6 @@ from django.conf import settings
from django.db import connection, transaction
from django.db.models import Sum, Max
from apiclient.discovery import build
import requests
import commonware.log

Просмотреть файл

@ -0,0 +1,42 @@
@import 'lib';
th.amount, td.amount {
text-align: right;
}
#tx-filters {
margin-bottom: 52px;
label {
display: inline-block;
margin-right: 5px;
text-align: right;
width: 110px;
}
.date-to label {
width: 50px
}
button {
bottom: 26px;
float: right;
position: relative;
}
.form-elem, .errorlist, .errorlist li {
display: inline;
}
.errorlist li {
left: 5px;
position: relative;
}
}
.date-from {
float: left;
}
.form-row {
margin-bottom: 13px;
}
.results-found {
clear: both;
display: block;
margin-bottom: 13px;
}

Просмотреть файл

@ -0,0 +1,146 @@
@import '../devreg/lib';
/* data grids */
.data-grid-top {
border-bottom: 1px solid #A5BFCE;
}
.data-grid-bottom {
border-top: 1px solid #A5BFCE;
}
.data-grid-content {
padding: 5px;
ol.pagination {
margin: 0;
}
&:after {
content: ".";
display: block;
clear: both;
height: 0;
visibility: hidden;
}
}
table.data-grid {
margin-bottom: 0;
width: 100%;
thead th {
background: @faded-blue;
a {
&:active, &:hover, &:visited {
color: #36b;
}
}
&.ordered {
.gradient-two-color(@faded-blue, @border-blue);
a {
color: #137;
&:active, &:hover, &:visited {
color: #039;
}
}
}
}
tr {
td {
border-top: 1px dotted @border-gray;
}
a {
display: block;
}
&:nth-child(odd) {
background-color: #FAFAFA;
}
&:nth-child(even) {
background-color: @white;
}
}
.addon-locked {
width: 16px;
height: 16px;
position: relative;
top: 4px;
}
.locked .addon-locked {
background: url(../../img/mkt/icons/mkt-reviewer-icons.png) no-repeat top left;
background-position: -16px -16px;
}
ul {
margin: 0;
}
}
@media (min-width: @tablet-min) {
.data-grid {
thead th {
&:nth-child(3) {
min-width: 7em;
}
&:nth-child(4) {
min-width: 8em;
}
&:nth-child(5) {
min-width: 90px;
}
&:nth-child(6) {
min-width: 7em;
}
&:nth-child(7) {
min-width: 7em;
}
&:nth-child(8) {
min-width: 10em;
}
}
tr {
th, td {
padding: 7px 10px;
}
}
th {
line-height: 1.3;
}
.addon-row a {
max-width: 600px;
}
}
}
@media (max-width: @landscape-max) {
.data-grid {
tr {
th, td {
border: 1px solid @faint-gray;
line-height: 12px;
font-size: 11px;
padding: 4px 2px;
text-align: center;
}
// Don't add borders around table cell for locked icon.
th:nth-child(1),
td:nth-child(1) {
border-right: 0;
}
th:nth-child(2),
td:nth-child(2) {
border-left: 0;
}
}
thead th {
&:nth-child(2) {
max-width: 300px;
}
&:nth-child(6) {
min-width: 55px;
}
&:nth-child(7) {
min-width: 45px;
}
}
th {
font-weight: 400;
vertical-align: middle;
}
}
}

Просмотреть файл

@ -230,27 +230,6 @@ header {
.ed-sprite-sort-desc {
background-position: right -435px;
}
/* data grids on queue pages */
.data-grid-top {
border-bottom: 1px solid #A5BFCE;
}
.data-grid-bottom {
border-top: 1px solid #A5BFCE;
}
.data-grid-content {
padding: 5px;
ol.pagination {
margin: 0;
}
&:after {
content: ".";
display: block;
clear: both;
height: 0;
visibility: hidden;
}
}
.queue-outer {
.border-radius(5px);
border: 4px solid #E0EFFD;
@ -273,34 +252,6 @@ header {
@media (min-width: @tablet-min) {
.data-grid {
thead th {
&:nth-child(3) {
min-width: 7em;
}
&:nth-child(4) {
min-width: 8em;
}
&:nth-child(5) {
min-width: 90px;
}
&:nth-child(6) {
min-width: 7em;
}
&:nth-child(7) {
min-width: 7em;
}
&:nth-child(8) {
min-width: 10em;
}
}
tr {
th, td {
padding: 7px 10px;
}
}
th {
line-height: 1.3;
}
.addon-row a {
max-width: 600px;
}
@ -311,63 +262,6 @@ header {
width: 121px;
}
table.data-grid {
margin-bottom: 0;
width: 100%;
thead th {
background: @faded-blue;
&:first-child {
width: 0;
}
a {
&:active, &:hover, &:visited {
color: #36b;
}
}
&.ordered {
.gradient-two-color(@faded-blue, @border-blue);
a {
color: #137;
&:active, &:hover, &:visited {
color: #039;
}
}
}
}
tr {
td {
border-top: 1px dotted @border-gray;
}
a {
display: block;
}
&:nth-child(odd) {
background-color: #FAFAFA;
}
&:nth-child(even) {
background-color: @white;
}
}
.addon-locked {
width: 16px;
height: 16px;
position: relative;
top: 4px;
}
.locked .addon-locked {
background: url(../../img/mkt/icons/mkt-reviewer-icons.png) no-repeat top left;
background-position: -16px -16px;
}
ul {
margin: 0;
}
time {
color: @medium-gray;
font-size: 11px;
line-height: 12px;
}
}
/* Version notes */
#popup-notes .version-notes {
overflow: auto;
@ -559,6 +453,11 @@ table.data-grid tr.comments td {
border-top: none;
background: #def;
}
thead th {
&:first-child {
width: 0;
}
}
.waiting_old, .waiting_med, .waiting_new {
height: 20px;
@ -1368,39 +1267,6 @@ iframe#manifest-contents {
}
}
.data-grid {
tr {
th, td {
border: 1px solid @faint-gray;
line-height: 12px;
font-size: 11px;
padding: 4px 2px;
text-align: center;
}
// Don't add borders around table cell for locked icon.
th:nth-child(1),
td:nth-child(1) {
border-right: 0;
}
th:nth-child(2),
td:nth-child(2) {
border-left: 0;
}
}
thead th {
&:nth-child(2) {
max-width: 300px;
}
&:nth-child(6) {
min-width: 55px;
}
&:nth-child(7) {
min-width: 45px;
}
}
th {
font-weight: 400;
vertical-align: middle;
}
.addon-row a {
display: block;
font-size: 12px;

Просмотреть файл

@ -0,0 +1,3 @@
INSERT INTO waffle_switch_mkt (name, active, created, modified, note)
VALUES ('view-transactions', 0, NOW(), NOW(),
'Enable transaction pages on Marketplace.');

Просмотреть файл

@ -35,6 +35,9 @@ CSS = {
'css/common/forms.less',
'css/devreg/devhub-forms.less',
# Tables.
'css/mkt/data-grid.less',
# Landing page
'css/devreg/landing.less',
@ -45,6 +48,7 @@ CSS = {
'css/devreg/in-app-config.less',
'css/devreg/payments.less',
'css/devreg/refunds.less',
'css/devreg/transactions.less',
'css/devreg/status.less',
# Image Uploads (used for "Edit Listing" Images and Submission).
@ -72,6 +76,7 @@ CSS = {
'mkt/reviewers': (
'css/mkt/buttons.less',
'css/mkt/ratings.less',
'css/mkt/data-grid.less',
'css/mkt/reviewers.less',
'css/mkt/themes_review.less',
'css/mkt/paginator.less',

Просмотреть файл

@ -5,6 +5,7 @@ import os
from django import forms
from django.conf import settings
from django.forms.extras.widgets import SelectDateWidget
from django.forms.models import formset_factory, modelformset_factory
from django.template.defaultfilters import filesizeformat
@ -34,6 +35,7 @@ import mkt
from mkt.constants import APP_IMAGE_SIZES, MAX_PACKAGED_APP_SIZE
from mkt.constants.ratingsbodies import (RATINGS_BY_NAME, ALL_RATINGS,
RATINGS_BODIES)
from mkt.site.forms import AddonChoiceField
from mkt.webapps.models import (AddonExcludedRegion, ContentRating, ImageAsset,
Webapp)
@ -849,3 +851,26 @@ class AppFormTechnical(addons.forms.AddonFormBase):
af.update(uses_flash=bool(uses_flash))
return super(AppFormTechnical, self).save(commit=True)
class TransactionFilterForm(happyforms.Form):
app = AddonChoiceField(queryset=None, required=False, label=_lazy(u'App'))
transaction_type = forms.ChoiceField(
required=False, label=_lazy(u'Transaction Type'),
choices=[(None, '')] + amo.MKT_TRANSACTION_CONTRIB_TYPES.items())
transaction_id = forms.CharField(
required=False, label=_lazy(u'Transaction ID'))
current_year = datetime.today().year
years = [current_year - x for x in range(current_year - 2012)]
date_from = forms.DateTimeField(
required=False, widget=SelectDateWidget(years=years),
label=_lazy(u'From'))
date_to = forms.DateTimeField(
required=False, widget=SelectDateWidget(years=years),
label=_lazy(u'To'))
def __init__(self, *args, **kwargs):
self.apps = kwargs.pop('apps', [])
super(TransactionFilterForm, self).__init__(*args, **kwargs)
self.fields['app'].queryset = self.apps

Просмотреть файл

@ -0,0 +1,67 @@
{% extends 'developers/base_impala.html' %}
{% from 'site/helpers/form_row.html' import form_row %}
{% set title = _('Transaction Details') %}
{% block title %}{{ hub_page_title(title) }}{% endblock %}
{% block content %}
<header>
{{ hub_breadcrumbs(addon, items=[(None, title)]) }}
<h1>{{ title }}</h1>
</header>
<div id="tx-filters" class="island">
<form>
{{ form_row(form, ('app',)) }}
{{ form_row(form, ('transaction_type',)) }}
{{ form_row(form, ('transaction_id',)) }}
<div class="date-from">
{{ form_row(form, ('date_from',)) }}
</div>
<div class="date-to">
{{ form_row(form, ('date_to',)) }}
</div>
<button type="submit">{{ _('Filter Transactions') }}</button>
</form>
</div>
<strong class="results-found">{{ ngettext('{num} transaction found',
'{num} transactions found',
count)|f(num=count) }}</strong>
<div class="island tabcontent">
{% if not transactions.paginator.count %}
<p class="no-results">
{{ _('Your apps currently have no transactions.') }}
</p>
{% else %}
<table class="data-grid">
<thead>
<tr>
<th>{{ _('App') }}</th>
<th>{{ _('Date') }}</th>
<th>{{ _('Type') }}</th>
<th>{{ _('Transaction ID') }}</th>
<th>{{ _('Currency') }}</th>
<th class="amount">{{ _('Amount') }}</th>
</tr>
</thead>
<tbody>
{% for transaction in transactions.object_list %}
<tr>
<td>{{ transaction.addon.name }}</td>
<td>{{ transaction.created|datetime }}</td>
<td>{{ CONTRIB_TYPES[transaction.type] }}</td>
<td>{{ transaction.transaction_id }}</td>
<td>{{ transaction.currency }}</td>
{% if transaction.amount %}
<td class="amount">{{ transaction.amount|numberfmt }}</td>
{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
{{ transactions|impala_paginator }}
{% endblock %}

Просмотреть файл

@ -12,13 +12,13 @@ from test_utils import RequestFactory
import amo
import amo.tests
from amo.tests import app_factory
from amo.tests.test_helpers import get_image_path
from addons.models import Addon, AddonCategory, Category
from files.helpers import copyfileobj
import mkt
from mkt.developers import forms
from mkt.site.fixtures import fixture
from mkt.webapps.models import (AddonExcludedRegion as AER, ContentRating,
Webapp)
@ -283,3 +283,34 @@ class TestPackagedAppForm(amo.tests.AMOPaths, amo.tests.WebappTestCase):
eq_(validation['messages'][0]['message'],
[u'Packaged app too large for submission.',
u'Packages must be less than 5 bytes.'])
class TestTransactionFilterForm(amo.tests.TestCase):
def setUp(self):
(app_factory(), app_factory())
# Need queryset to initialize form.
self.apps = Webapp.objects.all()
self.data = {
'app': self.apps[0].id,
'transaction_type': 1,
'transaction_id': 1,
'date_from_day': '1',
'date_from_month': '1',
'date_from_year': '2012',
'date_to_day': '1',
'date_to_month': '1',
'date_to_year': '2013',
}
def test_basic(self):
"""Test the form doesn't crap out."""
form = forms.TransactionFilterForm(self.data, apps=self.apps)
eq_(form.is_valid(), True)
def test_app_choices(self):
"""Test app choices."""
form = forms.TransactionFilterForm(self.data, apps=self.apps)
for app in self.apps:
assertion = (app.id, app.name) in form.fields['app'].choices
assert assertion, '(%s, %s) not in choices' % (app.id, app.name)

Просмотреть файл

@ -8,6 +8,7 @@ from contextlib import contextmanager
from django.conf import settings
from django.core.files.storage import default_storage as storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test.client import RequestFactory
import mock
import waffle
@ -19,7 +20,7 @@ from pyquery import PyQuery as pq
import amo
import amo.tests
from addons.models import Addon, AddonDeviceType, AddonUpsell, AddonUser
from amo.tests import assert_no_validation_errors
from amo.tests import app_factory, assert_no_validation_errors
from amo.tests.test_helpers import get_image_path
from amo.urlresolvers import reverse
from amo.utils import urlparams
@ -27,6 +28,7 @@ from browse.tests import test_default_sort, test_listing_sort
from files.models import FileUpload
from files.tests.test_models import UploadTest as BaseUploadTest
from market.models import AddonPremium, Price
from stats.models import Contribution
from translations.models import Translation
from users.models import UserProfile
from versions.models import Version
@ -35,6 +37,8 @@ import mkt
from mkt.constants import MAX_PACKAGED_APP_SIZE
from mkt.developers import tasks
from mkt.developers.models import ActivityLog
from mkt.developers.views import _filter_transactions, _get_transactions
from mkt.site.fixtures import fixture
from mkt.submit.models import AppSubmissionChecklist
from mkt.webapps.models import Webapp
@ -1039,3 +1043,72 @@ class TestTerms(amo.tests.TestCase):
eq_(doc('#site-notice').length, 0)
eq_(doc('#dev-agreement').length, 1)
eq_(doc('#agreement-form').length, 0)
class TestTransactionList(amo.tests.TestCase):
fixtures = fixture('user_999')
def setUp(self):
"""Create and set up apps for some filtering fun."""
self.create_switch(name='view-transactions')
self.url = reverse('mkt.developers.transactions')
self.client.login(username='regular@mozilla.com', password='password')
self.apps = [app_factory(), app_factory()]
self.user = UserProfile.objects.get(id=999)
for app in self.apps:
AddonUser.objects.create(addon=app, user=self.user)
# Set up transactions.
tx0 = Contribution.objects.create(addon=self.apps[0],
type=amo.CONTRIB_PURCHASE,
transaction_id=12345)
tx1 = Contribution.objects.create(addon=self.apps[1],
type=amo.CONTRIB_REFUND,
transaction_id=67890)
tx0.update(created=datetime.date(2011, 12, 25))
tx1.update(created=datetime.date(2012, 1, 1))
self.txs = [tx0, tx1]
def test_200(self):
r = self.client.get(self.url)
eq_(r.status_code, 200)
def test_own_apps(self):
"""Only user's transactions are shown."""
app_factory()
r = RequestFactory().get(self.url)
r.user = self.user
transactions = _get_transactions(r)[1]
self.assertSetEqual([tx.addon for tx in transactions], self.apps)
def test_filter(self):
"""For each field in the form, run it through view and check results.
"""
tx0 = self.txs[0]
tx1 = self.txs[1]
self.do_filter(self.txs)
self.do_filter([tx0], app=tx0.id)
self.do_filter([tx1], app=tx1.id)
self.do_filter([tx0], transaction_type=tx0.type)
self.do_filter([tx1], transaction_type=tx1.type)
self.do_filter([tx0], transaction_id=tx0.transaction_id)
self.do_filter([tx1], transaction_id=tx1.transaction_id)
self.do_filter(self.txs, date_from=datetime.date(2011, 12, 1))
self.do_filter([tx1], date_from=datetime.date(2011, 12, 30),
date_to=datetime.date(2012, 2, 1))
def do_filter(self, expected_txs, **kw):
"""Checks that filter returns the expected ids
expected_ids -- list of app ids expected in the result.
"""
qs = _filter_transactions(Contribution.objects.all(), kw)
self.assertSetEqual(qs.values_list('id', flat=True),
[tx.id for tx in expected_txs])

Просмотреть файл

@ -132,6 +132,8 @@ urlpatterns = decorate(write, patterns('',
views.docs, name='mkt.developers.docs'),
url('^statistics/', include(all_apps_stats_patterns)),
url('^transactions/', views.transactions,
name='mkt.developers.transactions'),
# Bango-specific stuff.
url('^bango/', include(bango_patterns('bango'))),

Просмотреть файл

@ -37,6 +37,7 @@ from files.models import File, FileUpload
from files.utils import parse_addon
from lib.cef_loggers import inapp_cef
from market.models import Refund
from stats.models import Contribution
from translations.models import delete_translation
from users.models import UserProfile
from users.views import _login
@ -49,7 +50,7 @@ from mkt.developers.forms import (AppFormBasic, AppFormDetails, AppFormMedia,
AppFormSupport, AppFormTechnical,
CategoryForm, ImageAssetFormSet,
NewPackagedAppForm, PreviewFormSet,
trap_duplicate)
TransactionFilterForm, trap_duplicate)
from mkt.developers.forms_payments import InappConfigForm
from mkt.developers.utils import check_upload
from mkt.inapp_pay.models import InappConfig
@ -467,7 +468,8 @@ def refresh_manifest(request, addon_id, addon, webapp=False):
@json_view
def _upload_manifest(request, is_standalone=False):
form = forms.NewManifestForm(request.POST, is_standalone=is_standalone)
if not is_standalone and waffle.switch_is_active('webapps-unique-by-domain'):
if (not is_standalone and
waffle.switch_is_active('webapps-unique-by-domain')):
# Helpful error if user already submitted the same manifest.
dup_msg = trap_duplicate(request, request.POST.get('manifest'))
if dup_msg:
@ -871,3 +873,39 @@ def blocklist(request, addon):
messages.info(request, _('App already blocklisted.'))
return redirect(addon.get_dev_url('versions'))
@waffle_switch('view-transactions')
@login_required
def transactions(request):
form, transactions = _get_transactions(request)
return jingo.render(
request, 'developers/transactions.html',
{'form': form,
'CONTRIB_TYPES': amo.CONTRIB_TYPES,
'count': transactions.count(),
'transactions': amo.utils.paginate(request,
transactions, per_page=50)})
def _get_transactions(request):
apps = addon_listing(request, webapp=True)[0]
transactions = Contribution.objects.filter(addon__in=list(apps))
form = TransactionFilterForm(request.GET, apps=apps)
if form.is_valid():
transactions = _filter_transactions(transactions, form.cleaned_data)
return form, transactions
def _filter_transactions(qs, data):
"""Handle search filters and queries for transactions."""
filter_mapping = {'app': 'addon_id',
'transaction_type': 'type',
'transaction_id': 'transaction_id',
'date_from': 'created__gte',
'date_to': 'created__lte'}
for form_field, db_field in filter_mapping.iteritems():
if data.get(form_field):
qs = qs.filter(**{db_field: data[form_field]})
return qs

Просмотреть файл

@ -1,4 +1,5 @@
{% extends 'reviewers/base.html' %}
{% from 'site/helpers/form_row.html' import form_row %}
{% block breadcrumbs %}
{{ reviewers_breadcrumbs(queue=tab) }}
@ -88,26 +89,17 @@
{% endif %}
<div id="advanced-search" class="c{% if not adv_searching %} hidden{% endif %}">
{% macro form_element(search_form, elems) -%}
<div class="form-row">
{% for elem in elems %}
{{ search_form[elem].label_tag() }}
<div class="form-elem">{{ search_form[elem] }}</div>
{% endfor %}
</div>
{%- endmacro %}
<div class="basic-filters">
{{ form_element(search_form,
{{ form_row(search_form,
('admin_review', 'has_editor_comment',
'has_info_request', 'waiting_time_days',
'app_type')) }}
</div>
<div class="device-type-select">
{{ form_element(search_form, ('device_type_ids',)) }}
{{ form_row(search_form, ('device_type_ids',)) }}
</div>
<div class="premium-type-select">
{{ form_element(search_form, ('premium_type_ids',)) }}
{{ form_row(search_form, ('premium_type_ids',)) }}
</div>
</div>

Просмотреть файл

@ -0,0 +1,9 @@
{% macro form_row(search_form, elems) -%}
<div class="form-row">
{% for elem in elems %}
{{ search_form[elem].label_tag() }}
<div class="form-elem">{{ search_form[elem] }}</div>
{{ search_form[elem].errors }}
{% endfor %}
</div>
{%- endmacro %}