Split services into plans

This commit is contained in:
Marc Aymerich 2014-11-18 13:59:21 +00:00
parent ae7c5b7969
commit 9b87ef5e0d
20 changed files with 296 additions and 141 deletions

10
TODO.md
View File

@ -1,4 +1,4 @@
TODO ====
==== TODO ====
* scape strings before executing scripts in order to prevent exploits: django templates automatically scapes things. Most important is to ensuer that all escape ' to &quot
* Don't store passwords and other service parameters that can be changed by the services i.e. mailman, vps etc. Find an execution mechanism that trigger `change_password()`
@ -152,7 +152,6 @@ Remember that, as always with QuerySets, any subsequent chained methods which im
* Secondary user home in /home/secondaryuser and simlink to /home/main/webapps/app so it can have private storage?
* Grant permissions to systemusers, the problem of creating a related permission model is out of sync with the server-side. evaluate tradeoff
* Secondaryusers home should be under mainuser home. i.e. /home/mainuser/webapps/seconduser_webapp/
* Make one dedicated CGI user for each account only for CGI execution (fpm/fcgid). Different from the files owner, and without W permissions, so attackers can not inject backdors and malware.
* In most cases we can prevent the creation of files for the CGI users, preventing attackers to upload and executing PHPShells.
* Make main systemuser able to write/read everything on its home, including stuff created by the CGI user and secondary users
@ -169,8 +168,13 @@ Remember that, as always with QuerySets, any subsequent chained methods which im
* Directory Protection on webapp and use webapp path as base path (validate)
* User [Group] webapp/website option (validation) which overrides default mainsystemuser
* validate systemuser.home
* validate systemuser.home on server-side
* webapp backend option compatibility check?
* admin systemuser home/directory, add default home and empty directory with has_shell on admin
* Backendlog doesn't show during execution, transaction isolation or what?
* Resource used_list_display=True, allocated_list_displat=True, allow resources to show up on list_display

View File

@ -43,10 +43,7 @@ def get_services():
def get_accounts():
childrens = [
items.MenuItem(_("Accounts"),
reverse('admin:accounts_account_changelist'))
]
childrens=[]
if isinstalled('orchestra.apps.payments'):
url = reverse('admin:payments_transactionprocess_changelist')
childrens.append(items.MenuItem(_("Transaction processes"), url))
@ -68,7 +65,7 @@ def get_administration_items():
if isinstalled('orchestra.apps.services'):
url = reverse('admin:services_service_changelist')
childrens.append(items.MenuItem(_("Services"), url))
url = reverse('admin:services_plan_changelist')
url = reverse('admin:plans_plan_changelist')
childrens.append(items.MenuItem(_("Plans"), url))
if isinstalled('orchestra.apps.orchestration'):
route = reverse('admin:orchestration_route_changelist')

View File

@ -40,7 +40,7 @@ class APIRoot(views.APIView):
if model in services:
group = 'services'
menu = services[model].menu
elif model in accounts:
if model in accounts:
group = 'accountancy'
menu = accounts[model].menu
if group and menu:

View File

@ -6,7 +6,7 @@ from django.db.models.loading import get_model
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from orchestra.core import services
from orchestra.core import services, accounts
from orchestra.utils import send_email_template
from . import settings
@ -154,3 +154,4 @@ class Account(auth.AbstractBaseUser):
services.register(Account, menu=False)
accounts.register(Account)

View File

@ -108,18 +108,33 @@ class MysqlDisk(ServiceMonitor):
""" % context
))
def prepare(self):
""" slower """
self.append(textwrap.dedent("""\
function monitor () {
{ du -bs "/var/lib/mysql/$1" || echo 0; } | awk {'print $1'}
}"""))
# Slower way
#self.append(textwrap.dedent("""\
# function monitor () {
# mysql -B -e "
# SELECT IFNULL(sum(data_length + index_length), 0) 'Size'
# FROM information_schema.TABLES
# WHERE table_schema = '$1';
# " | tail -n 1
# }"""))
def monitor(self, db):
if db.type != db.MYSQL:
return
context = self.get_context(db)
self.append(textwrap.dedent("""\
echo %(db_id)s $(mysql -B -e '"
SELECT sum( data_length + index_length ) "Size"
FROM information_schema.TABLES
WHERE table_schema = "gisp"
GROUP BY table_schema;' | tail -n 1) \
""" % context
))
self.append("echo %(db_id)s $(monitor %(db_name)s)" % context)
def monitor(self, db):
if db.type != db.MYSQL:
return
context = self.get_context(db)
self.append('echo %(db_id)s $(monitor "%(db_name)s")' % context)
def get_context(self, db):
return {

View File

@ -41,6 +41,7 @@ class ListAdmin(ChangePasswordAdminMixin, SelectAccountAdminMixin, ExtendedModel
'fields': ('password',),
}),
)
search_fields = ('name', 'address_name', 'address_domain__name', 'account__username')
readonly_fields = ('account_link',)
change_readonly_fields = ('name',)
form = ListChangeForm

View File

@ -206,16 +206,25 @@ class AutoresponseBackend(ServiceController):
class MaildirDisk(ServiceMonitor):
"""
Maildir disk usage based on Dovecot maildirsize file
http://wiki2.dovecot.org/Quota/Maildir
"""
model = 'mailboxes.Mailbox'
resource = ServiceMonitor.DISK
verbose_name = _("Maildir disk usage")
def prepare(self):
current_date = self.current_date.strftime("%Y-%m-%d %H:%M:%S %Z")
self.append(textwrap.dedent("""\
function monitor () {
awk 'NR>1 {s+=$1} END {print s}' $1 || echo 0
}"""))
def monitor(self, mailbox):
context = self.get_context(mailbox)
self.append(
"SIZE=$(awk 'NR>1 {s+=$1} END {print s}' %(maildir_path)s)\n"
"echo %(object_id)s ${SIZE:-0}" % context
)
self.append("echo %(object_id)s $(monitor %(maildir_path)s)" % context)
def get_context(self, mailbox):
context = {

View File

@ -17,5 +17,5 @@ ORDERS_EXCLUDED_APPS = getattr(settings, 'ORDERS_EXCLUDED_APPS', (
'sessions',
'orchestration',
'bills',
# Do not put services here (plans)
'services',
))

View File

View File

@ -0,0 +1,37 @@
from django.contrib import admin
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ExtendedModelAdmin
from orchestra.admin.filters import UsedContentTypeFilter
from orchestra.admin.utils import insertattr
from orchestra.apps.accounts.admin import AccountAdminMixin
from orchestra.apps.services.models import Service
from .models import Plan, ContractedPlan, Rate
class RateInline(admin.TabularInline):
model = Rate
ordering = ('plan', 'quantity')
class PlanAdmin(ExtendedModelAdmin):
list_display = ('name', 'is_default', 'is_combinable', 'allow_multiple')
list_filter = ('is_default', 'is_combinable', 'allow_multiple')
fields = ('verbose_name', 'name', 'is_default', 'is_combinable', 'allow_multiple')
prepopulated_fields = {
'name': ('verbose_name',)
}
change_readonly_fields = ('name',)
inlines = [RateInline]
class ContractedPlanAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ('plan', 'account_link')
list_filter = ('plan__name',)
admin.site.register(Plan, PlanAdmin)
admin.site.register(ContractedPlan, ContractedPlanAdmin)
insertattr(Service, 'inlines', RateInline)

View File

@ -0,0 +1,90 @@
import decimal
from django.core.validators import ValidationError
from django.db import models
from django.utils.translation import ugettext_lazy as _
from orchestra.core import services, accounts
from orchestra.core.validators import validate_name
from orchestra.models import queryset
from . import rating
class Plan(models.Model):
name = models.CharField(_("name"), max_length=32, unique=True, validators=[validate_name])
verbose_name = models.CharField(_("verbose_name"), max_length=128, blank=True)
is_default = models.BooleanField(_("default"), default=False,
help_text=_("Designates whether this plan is used by default or not."))
is_combinable = models.BooleanField(_("combinable"), default=True,
help_text=_("Designates whether this plan can be combined with other plans or not."))
allow_multiple = models.BooleanField(_("allow multiple"), default=False,
help_text=_("Designates whether this plan allow for multiple contractions."))
def __unicode__(self):
return self.name
def clean(self):
self.verbose_name = self.verbose_name.strip()
def get_verbose_name(self):
return self.verbose_name or self.name
class ContractedPlan(models.Model):
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='contracts')
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
related_name='plans')
class Meta:
verbose_name_plural = _("plans")
def __unicode__(self):
return str(self.plan)
def clean(self):
if not self.pk and not self.plan.allow_multiples:
if ContractedPlan.objects.filter(plan=self.plan, account=self.account).exists():
raise ValidationError("A contracted plan for this account already exists.")
class RateQuerySet(models.QuerySet):
group_by = queryset.group_by
def by_account(self, account):
# Default allways selected
return self.filter(
Q(plan__is_default=True) |
Q(plan__contracts__account=account)
).order_by('plan', 'quantity').select_related('plan')
class Rate(models.Model):
STEP_PRICE = 'STEP_PRICE'
MATCH_PRICE = 'MATCH_PRICE'
RATE_METHODS = {
STEP_PRICE: rating.step_price,
MATCH_PRICE: rating.match_price,
}
service = models.ForeignKey('services.Service', verbose_name=_("service"),
related_name='rates')
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='rates')
quantity = models.PositiveIntegerField(_("quantity"), null=True, blank=True)
price = models.DecimalField(_("price"), max_digits=12, decimal_places=2)
objects = RateQuerySet.as_manager()
class Meta:
unique_together = ('service', 'plan', 'quantity')
def __unicode__(self):
return "{}-{}".format(str(self.price), self.quantity)
@classmethod
def get_methods(self):
return self.RATE_METHODS
accounts.register(ContractedPlan)
services.register(ContractedPlan, menu=False)

View File

@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import orchestra.core.validators
import orchestra.apps.resources.validators
import django.utils.timezone
import orchestra.models.fields
class Migration(migrations.Migration):
dependencies = [
('djcelery', '__first__'),
('contenttypes', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='MonitorData',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('monitor', models.CharField(max_length=256, verbose_name='monitor', choices=[(b'Apache2Traffic', '[M] Apache 2 Traffic'), (b'MaildirDisk', '[M] Maildir disk usage'), (b'MailmanSubscribers', '[M] Mailman subscribers'), (b'MailmanTraffic', '[M] Mailman traffic'), (b'FTPTraffic', '[M] Main FTP traffic'), (b'SystemUserDisk', '[M] Main user disk'), (b'MysqlDisk', '[M] MySQL disk'), (b'OpenVZTraffic', '[M] OpenVZTraffic')])),
('object_id', models.PositiveIntegerField(verbose_name='object id')),
('created_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='created')),
('value', models.DecimalField(verbose_name='value', max_digits=16, decimal_places=2)),
('content_type', models.ForeignKey(verbose_name='content type', to='contenttypes.ContentType')),
],
options={
'get_latest_by': 'id',
'verbose_name_plural': 'monitor data',
},
bases=(models.Model,),
),
migrations.CreateModel(
name='Resource',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('name', models.CharField(help_text='Required. 32 characters or fewer. Lowercase letters, digits and hyphen only.', max_length=32, verbose_name='name', validators=[orchestra.core.validators.validate_name])),
('verbose_name', models.CharField(max_length=256, verbose_name='verbose name')),
('period', models.CharField(default=b'LAST', help_text='Operation used for aggregating this resource monitored data.', max_length=16, verbose_name='period', choices=[(b'LAST', 'Last'), (b'MONTHLY_SUM', 'Monthly Sum'), (b'MONTHLY_AVG', 'Monthly Average')])),
('on_demand', models.BooleanField(default=False, help_text='If enabled the resource will not be pre-allocated, but allocated under the application demand', verbose_name='on demand')),
('default_allocation', models.PositiveIntegerField(help_text='Default allocation value used when this is not an on demand resource', null=True, verbose_name='default allocation', blank=True)),
('unit', models.CharField(help_text='The unit in which this resource is represented. For example GB, KB or subscribers', max_length=16, verbose_name='unit')),
('scale', models.CharField(help_text='Scale in which this resource monitoring resoults should be prorcessed to match with unit. e.g. <tt>10**9</tt>', max_length=32, verbose_name='scale', validators=[orchestra.apps.resources.validators.validate_scale])),
('disable_trigger', models.BooleanField(default=False, help_text='Disables monitors exeeded and recovery triggers', verbose_name='disable trigger')),
('monitors', orchestra.models.fields.MultiSelectField(blank=True, help_text='Monitor backends used for monitoring this resource.', max_length=256, verbose_name='monitors', choices=[(b'Apache2Traffic', '[M] Apache 2 Traffic'), (b'MaildirDisk', '[M] Maildir disk usage'), (b'MailmanSubscribers', '[M] Mailman subscribers'), (b'MailmanTraffic', '[M] Mailman traffic'), (b'FTPTraffic', '[M] Main FTP traffic'), (b'SystemUserDisk', '[M] Main user disk'), (b'MysqlDisk', '[M] MySQL disk'), (b'OpenVZTraffic', '[M] OpenVZTraffic')])),
('is_active', models.BooleanField(default=True, verbose_name='active')),
('content_type', models.ForeignKey(help_text='Model where this resource will be hooked.', to='contenttypes.ContentType')),
('crontab', models.ForeignKey(blank=True, to='djcelery.CrontabSchedule', help_text='Crontab for periodic execution. Leave it empty to disable periodic monitoring', null=True, verbose_name='crontab')),
],
options={
},
bases=(models.Model,),
),
migrations.CreateModel(
name='ResourceData',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('object_id', models.PositiveIntegerField(verbose_name='object id')),
('used', models.DecimalField(verbose_name='used', null=True, editable=False, max_digits=16, decimal_places=2)),
('updated_at', models.DateTimeField(verbose_name='updated', null=True, editable=False)),
('allocated', models.DecimalField(null=True, verbose_name='allocated', max_digits=8, decimal_places=2, blank=True)),
('content_type', models.ForeignKey(verbose_name='content type', to='contenttypes.ContentType')),
('resource', models.ForeignKey(related_name='dataset', verbose_name='resource', to='resources.Resource')),
],
options={
'verbose_name_plural': 'resource data',
},
bases=(models.Model,),
),
migrations.AlterUniqueTogether(
name='resourcedata',
unique_together=set([('resource', 'content_type', 'object_id')]),
),
migrations.AlterUniqueTogether(
name='resource',
unique_together=set([('name', 'content_type'), ('verbose_name', 'content_type')]),
),
]

View File

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('resources', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='resourcedata',
name='used',
field=models.DecimalField(verbose_name='used', null=True, editable=False, max_digits=16, decimal_places=3),
preserve_default=True,
),
]

View File

@ -4,34 +4,13 @@ from django.core.urlresolvers import reverse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from orchestra.admin import ChangeViewActionsMixin, ExtendedModelAdmin
from orchestra.admin import ChangeViewActionsMixin
from orchestra.admin.filters import UsedContentTypeFilter
from orchestra.apps.accounts.admin import AccountAdminMixin
from orchestra.core import services
from .actions import update_orders, view_help, clone
from .models import Plan, ContractedPlan, Rate, Service
class RateInline(admin.TabularInline):
model = Rate
ordering = ('plan', 'quantity')
class PlanAdmin(ExtendedModelAdmin):
list_display = ('name', 'is_default', 'is_combinable', 'allow_multiple')
list_filter = ('is_default', 'is_combinable', 'allow_multiple')
fields = ('verbose_name', 'name', 'is_default', 'is_combinable', 'allow_multiple')
prepopulated_fields = {
'name': ('verbose_name',)
}
change_readonly_fields = ('name',)
inlines = [RateInline]
class ContractedPlanAdmin(AccountAdminMixin, admin.ModelAdmin):
list_display = ('plan', 'account_link')
list_filter = ('plan__name',)
from .models import Service
class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
@ -56,7 +35,6 @@ class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
'on_cancel', 'payment_style', 'tax', 'nominal_price')
}),
)
inlines = [RateInline]
actions = [update_orders, clone]
change_view_actions = actions + [view_help]
@ -95,6 +73,4 @@ class ServiceAdmin(ChangeViewActionsMixin, admin.ModelAdmin):
return qs
admin.site.register(Plan, PlanAdmin)
admin.site.register(ContractedPlan, ContractedPlanAdmin)
admin.site.register(Service, ServiceAdmin)

View File

@ -9,78 +9,14 @@ from django.utils.functional import cached_property
from django.utils.module_loading import autodiscover_modules
from django.utils.translation import ugettext_lazy as _
from orchestra.core import caches, services, accounts, validators
from orchestra.core import caches, validators
from orchestra.core.validators import validate_name
from orchestra.models import queryset
from . import settings, rating
from . import settings
from .handlers import ServiceHandler
class Plan(models.Model):
name = models.CharField(_("name"), max_length=32, unique=True, validators=[validate_name])
verbose_name = models.CharField(_("verbose_name"), max_length=128, blank=True)
is_default = models.BooleanField(_("default"), default=False,
help_text=_("Designates whether this plan is used by default or not."))
is_combinable = models.BooleanField(_("combinable"), default=True,
help_text=_("Designates whether this plan can be combined with other plans or not."))
allow_multiple = models.BooleanField(_("allow multiple"), default=False,
help_text=_("Designates whether this plan allow for multiple contractions."))
def __unicode__(self):
return self.name
def clean(self):
self.verbose_name = self.verbose_name.strip()
def get_verbose_name(self):
return self.verbose_name or self.name
class ContractedPlan(models.Model):
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='contracts')
account = models.ForeignKey('accounts.Account', verbose_name=_("account"),
related_name='plans')
class Meta:
verbose_name_plural = _("plans")
def __unicode__(self):
return str(self.plan)
def clean(self):
if not self.pk and not self.plan.allow_multiples:
if ContractedPlan.objects.filter(plan=self.plan, account=self.account).exists():
raise ValidationError("A contracted plan for this account already exists.")
class RateQuerySet(models.QuerySet):
group_by = queryset.group_by
def by_account(self, account):
# Default allways selected
return self.filter(
Q(plan__is_default=True) |
Q(plan__contracts__account=account)
).order_by('plan', 'quantity').select_related('plan')
class Rate(models.Model):
service = models.ForeignKey('services.Service', verbose_name=_("service"),
related_name='rates')
plan = models.ForeignKey(Plan, verbose_name=_("plan"), related_name='rates')
quantity = models.PositiveIntegerField(_("quantity"), null=True, blank=True)
price = models.DecimalField(_("price"), max_digits=12, decimal_places=2)
objects = RateQuerySet.as_manager()
class Meta:
unique_together = ('service', 'plan', 'quantity')
def __unicode__(self):
return "{}-{}".format(str(self.price), self.quantity)
autodiscover_modules('handlers')
@ -105,12 +41,6 @@ class Service(models.Model):
REFUND = 'REFUND'
PREPAY = 'PREPAY'
POSTPAY = 'POSTPAY'
STEP_PRICE = 'STEP_PRICE'
MATCH_PRICE = 'MATCH_PRICE'
RATE_METHODS = {
STEP_PRICE: rating.step_price,
MATCH_PRICE: rating.match_price,
}
description = models.CharField(_("description"), max_length=256, unique=True)
content_type = models.ForeignKey(ContentType, verbose_name=_("content type"),
@ -197,11 +127,12 @@ class Service(models.Model):
default=BILLING_PERIOD)
rate_algorithm = models.CharField(_("rate algorithm"), max_length=16,
help_text=_("Algorithm used to interprete the rating table."),
# TODO this should be dynamic, retrieved from rate (plans) app
choices=(
(STEP_PRICE, _("Step price")),
(MATCH_PRICE, _("Match price")),
('STEP_PRICE', _("Step price")),
('MATCH_PRICE', _("Match price")),
),
default=STEP_PRICE)
default='STEP_PRICE')
on_cancel = models.CharField(_("on cancel"), max_length=16,
help_text=_("Defines the cancellation behaviour of this service."),
choices=(
@ -297,7 +228,8 @@ class Service(models.Model):
@property
def rate_method(self):
return self.RATE_METHODS[self.rate_algorithm]
rate_model = type(self).rates.related.model
return rate_model.get_methods()[self.rate_algorithm]
def update_orders(self, commit=True):
order_model = get_model(settings.SERVICES_ORDER_MODEL)
@ -306,7 +238,3 @@ class Service(models.Model):
for instance in related_model.objects.all().select_related('account'):
updates += order_model.update_orders(instance, service=self, commit=commit)
return updates
accounts.register(ContractedPlan)
services.register(ContractedPlan, menu=False)

View File

@ -31,7 +31,7 @@ class SystemUserSerializer(AccountSerializerMixin, HyperlinkedModelSerializer):
class Meta:
model = SystemUser
fields = (
'url', 'username', 'password', 'home', 'shell', 'groups', 'is_active',
'url', 'username', 'password', 'home', 'directory', 'shell', 'groups', 'is_active',
)
postonly_fields = ('username',)

View File

@ -72,10 +72,6 @@ INSTALLED_APPS = (
'orchestra.apps.orchestration',
'orchestra.apps.domains',
'orchestra.apps.systemusers',
# 'orchestra.apps.users',
# 'orchestra.apps.users.roles.mail',
# 'orchestra.apps.users.roles.jabber',
# 'orchestra.apps.users.roles.posix',
'orchestra.apps.mailboxes',
'orchestra.apps.lists',
'orchestra.apps.webapps',
@ -85,6 +81,7 @@ INSTALLED_APPS = (
'orchestra.apps.saas',
'orchestra.apps.issues',
'orchestra.apps.services',
'orchestra.apps.plans',
'orchestra.apps.orders',
'orchestra.apps.miscellaneous',
'orchestra.apps.bills',
@ -149,7 +146,7 @@ FLUENT_DASHBOARD_APP_GROUPS = (
'orchestra.apps.accounts.models.Account',
'orchestra.apps.contacts.models.Contact',
'orchestra.apps.orders.models.Order',
'orchestra.apps.services.models.ContractedPlan',
'orchestra.apps.plans.models.ContractedPlan',
'orchestra.apps.bills.models.Bill',
# 'orchestra.apps.payments.models.PaymentSource',
'orchestra.apps.payments.models.Transaction',
@ -167,7 +164,7 @@ FLUENT_DASHBOARD_APP_GROUPS = (
'orchestra.apps.resources.models.Resource',
'orchestra.apps.resources.models.Monitor',
'orchestra.apps.services.models.Service',
'orchestra.apps.services.models.Plan',
'orchestra.apps.plans.models.Plan',
'orchestra.apps.miscellaneous.models.MiscService',
),
'collapsible': True,
@ -195,7 +192,7 @@ FLUENT_DASHBOARD_APP_ICONS = {
'accounts/account': 'Face-monkey.png',
'contacts/contact': 'contact_book.png',
'orders/order': 'basket.png',
'services/contractedplan': 'ContractedPack.png',
'plans/contractedplan': 'ContractedPack.png',
'services/service': 'price.png',
'bills/bill': 'invoice.png',
'payments/paymentsource': 'card_in_use.png',
@ -210,7 +207,7 @@ FLUENT_DASHBOARD_APP_ICONS = {
'orchestration/backendlog': 'scriptlog.png',
'resources/resource': "gauge.png",
'resources/monitor': "Utilities-system-monitor.png",
'services/plan': 'Pack.png',
'plans/plan': 'Pack.png',
}
# Django-celery

View File

@ -18,7 +18,7 @@ class Register(object):
self._registry[model] = AttrDict(**{
'verbose_name': kwargs.get('verbose_name', model._meta.verbose_name),
'verbose_name_plural': plural,
'menu': kwargs.get('menu', True)
'menu': kwargs.get('menu', True),
})
def get(self, *args):