Package: django-allianceutils
Alliance UtilsA collection of utilities for django projects fromAlliance Software.InstallationUsageAPIAuthDecoratorsManagementCommandsChecksMiddlewareMigrationsModelsRulesSerializersTemplate TagsUtilChangelogInstallationpip install django-allianceutilsSystem RequirementsTested with django 3.2 and 4.2Pull requests accepted for other versions, but at minimum we test against current LTS versionsPython >=3.8UsageAPIMixinsSerializerOptInFieldsMixinRegulates fields exposed on a Serializer by default & as requested based on query parameters or context.Pass 'include_fields' / 'opt_in_fields' thru query params or context to use.multiple fields can either be separated by comma
eg,/?include_fields=first_name,email&opt_in_fields=gait_recognition_predictionor passed in the traditional list fashion
eg,/?include_fields=first_name&include_fields=email&opt_in_fields=gait_recognition_predictionor mixed eg,/?include_fields=first_name,email&include_fields=booBy default, all "fields" defined in serializer, minus those listed in "opt_in_fields" would be returned.If "include_fields" is supplied, only fields requested this way would be returned.If "opt_in_fields" is supplied, fields requested this way PLUS fields from #1 or #2 would be returned.Pinned fields are always returned (defaults to primary key)Usage:classUserSerializer(SerializerOptInFieldsMixin,ModelSerializer):classMeta:model=Userfields=("id","first_name","last_name","email","region","activated_at","is_staff",)# These fields only returned if explicitly requestedopt_in_only_fields=["activated_at","is_staff"]Permissionsregister_custom_permissionsCreates permissions that have no model by linking them to an empty content typeDjango creates permissions as part of
thepost_migratesignalUsagedefon_post_migrate(sender,**kwargs):register_custom_permissions("myapp",("my_perm","My Permission"))SimpleDjangoObjectPermissionsPermission class for Django Rest Framework that adds support for object level permissions.Differs from just DRF'sDjangoObjectPermissionsbecause itdoes not require a querysetuses the same permission for every request http method and ViewSet methodNotesThe default django permissions system willalways return Falseif given an object; you must be using another permissions backendAs perDRF documentation: get_object() is only required if you want to implement object-level permissionsWARNINGIf you overrideget_object()then you need tomanuallyinvokeself.check_object_permissions(self.request, obj)Will attempt to check permission both globally and on a per-object basis but considers it an error if the check returns True for bothUsageSeeDRF permissions policyfor details on apply Permissions policies globallyTo apply to a specific view you need to setpermission_requiredclassMyAPIView(SimpleDjangoObjectPermissions,APIView):permission_required='my_module.my_permission'permission_classes=[allianceutils.api.permissions.SimpleDjangoObjectPermissions]If you have no object level permissions (eg. from rules) then it will just do a static permission check.GenericDjangoViewsetPermissionsMap viewset actions to Django permissions.The model used for permission is extracted from the ViewSetIf you implementget_permission_modelon the ViewSet that will be usedOtherwise it will callget_queryseton the ViewSet and extract the model from the returned querysetTo alter this behaviour extendsGenericDjangoViewsetPermissionsand implementget_modelUsage example:class MyViewSet(GenericDjangoViewsetPermissions, viewsets.ModelViewSet):
    queryset = MyModel.objects.all()
    serializer_class = MySerializerGenericDjangoViewsetPermissions.default_actions_to_perms_mapdefines the default set of permissions. These can be extended or overridden usingactions_to_perms_map:class MyViewSet(GenericDjangoViewsetPermissions, viewsets.ModelViewSet):

    # ...

    actions_to_perms_map = {
        'create': []
    }No permissions will be required for the create action, but permissions for other actions will remain unchanged.By default permissions checks are passed the relevant model instance for per-object permission checksThis assumes that your backend doesn't ignore the model object (default django permissions simply ignore any object passed to a permissions check)Since there is no model object, functions decorated with@list_routewill passNoneas the permissions check objectParsersCamelCaseJSONParserParser that recursively turns camelcase keys into underscored keys for JSON data.
This can be set globally on theDEFAULT_PARSER_CLASSESsetting or on a ViewSet on theparser_classesproperty.CamelCaseMultiPartJSONParserParser that recursively turns camelcase keys into underscored keys for JSON data and handles file uploads.
This parser supports receiving JSON data where a field value anywhere in the structure can be a file.
This is achieved on the frontend by converting a structure like:{name:'Test',photo:File,}And converting it to{name:'Test',photo:'____ATTACHED_FILE_ID_1',}This is then set on a fieldjsonDataand the file is set on____ATTACHED_FILE_ID_1and submitted
as multipart.
This parser then handles parsing the JSON data into a dict and setting each attached file on the
correct key in the dict.
Note that this works with nested data (ie. any File anywhere in a nested JSON structure is supported).
To activate this behaviour theX-MultiPart-JSONheader must be set to '1' or 'true'. If this header
is not set it falls back to the default behaviour of MultiPartParser
This can be set globally on theDEFAULT_PARSER_CLASSESsetting or on a ViewSet on theparser_classesproperty.
Example frontend code to activate:letfileCount=0;constfiles={};constreplacer=(key,value)=>{if(valueinstanceofFile){constid=`____ATTACHED_FILE_ID_${fileCount++}`;files[id]=value;returnid;}returnvalue;};conststringifiedData=JSON.stringify(data,replacer);constbody=newFormData();constbody.append('jsonData',stringifiedData);for(const[fileKey,file]ofObject.entries(files)){body.append(fileKey,file);}// eg. using a presto EndpointawaitmyEndpoint.execute({body,headers:{// Remove default content type from endpoint (eg. json)'Content-Type':undefined,'X-MultiPart-JSON':true,},});RenderersCamelCaseJSONRendererRenderer that recursively turns underscore-cased keys into camel-cased keys.
This can be set globally on theDEFAULT_RENDERER_CLASSESsetting or on a ViewSet on therenderer_classesproperty.AuthMinimalModelBackendallianceutils.auth.backends.MinimalModelBackendReplaces the built-in djangoModelBackendProvides django model-based authenticationRemoves the default authorization (permissions checks) except for checkingis_superuserProfileModelBackendBackends for use withGenericUserProfile; see code examples thereallianceutils.auth.backends.ProfileModelBackendMixin- in combo withAuthenticationMiddlewarewill set user profiles onrequest.userIf you wantallianceutils.auth.backends.ProfileModelBackend- convenience class combined with case insensitive username & default django permissions backendthis depended onauthtoolswhich appears to have been
abandoned and does not work with django >= 3.
If using django 3 then we recommended that you create your own backend in your app:classProfileModelBackend(ProfileModelBackendMixin,MinimalModelBackend):# you'll need to implement case insensitivity either here or in the User ModelpassDecoratorsgzip_page_ajaxSmarter version of django'sgzip_page:If settings.DEBUG not set, will always gzipIf settings.DEBUG set, will gzip only if request is an ajax requestThis allows you to use django-debug-toolbar in DEBUG mode (if you gzip a response then the debug toolbar middleware won't run)Example@allianceutils.views.decorators.gzip_page_ajax
def my_view(request: HttpRequest) -> httpResponse:
    data = {
        "message": "Hello World",
    }
    return django.http.JsonResponse(data)method_cacheCaches the results of a method on the object instanceThere is no thread synchronization so in some circumstances the method may be called multiple times if multiple threads share the objectOnly works for regular object methods with no arguments other thanself.Does not support@classmethodor@staticmethodIf you want more powerful caching behaviour then you canusemethodtoolswrapcachetools(exampleshere)Similar to@cached_propertyexcept that it works on methods instead of propertiesDiffers from@lru_cache()in thatlru_cacheuses a single cache for each decorated functionlru_cachewill block garbage collection of values in the cacheAcache_clear()method is attached to the function but unlikelru_cacheit is scoped to an object instanceUsageclassMyViewSet(ViewSet):# ...@method_cachedefget_object(self):returnsuper().get_object()obj=MyViewSet()obj.get_object()isobj.get_object()obj.get_object.cache_clear()ManagementCommandsOptionalAppCommandA utility class that extendsdjango.core.management.base.BaseCommandand adds optional argument(s) for django appsIf app names are passed on the command linehandle_app_config()will be called with theAppConfigfor each app otherwise it will be called with every first-party app (as determined byisort)Example:class Command(allianceutils.management.commands.base.OptionalAppCommand):
    def add_arguments(self, parser):
        super().add_arguments(parser)
        parser.add_argument('--type', choices=('name', 'label'), default='name')

    def handle_app_config(self, app_config: AppConfig, **options):
        if options['type'] == 'name':
            print(f"Called with {app_config.name}")
        if options['type'] == 'label':
            print(f"Called with {app_config.label}")print_loggingDisplays the current logging configuration in a hierarchical fashionRequireslogging_treeto be installedChecksChecks with no configuration are functions that can be passed directly toregisterChecks that expect parameters are classes that need to be instantiatedSetting up django hooks:fromdjango.appsimportAppConfigfromdjango.core.checksimportregisterfromdjango.core.checksimportTagsfromallianceutils.checksimportcheck_adminsfromallianceutils.checksimportcheck_db_constraintsfromallianceutils.checksimportCheckExplicitTableNamesfromallianceutils.checksimportcheck_git_hooksfromallianceutils.checksimportCheckReversibleFieldNamesfromallianceutils.checksimportCheckUrlTrailingSlashclassMyAppConfig(AppConfig):# ...defready(self):register(check=check_admins,tags=Tags.admin,deploy=True)register(check=check_db_constraints,tags=Tags.database)register(check=CheckExplicitTableNames(),tags=Tags.models)register(check=check_git_hooks,tags=Tags.admin)register(check=CheckReversibleFieldNames(),tags=Tags.models)register(check=CheckUrlTrailingSlash(expect_trailing_slash=True),tags=Tags.url)CheckUrlTrailingSlashChecks that your URLs are consistent with thesettings.APPEND_SLASHArguments:ignore_attrs- skip checks on url patterns where an attribute of the pattern matches something in here (see example above)Most relevant attributes of aRegexURLResolver:_regex- string used for regex matching. Defaults to[r'^$']app_name- app name (only works forinclude()patterns). Defaults to['djdt'](django debug toolbar)namespace- pattern defines a namespacelookup_str- string defining view to use. Defaults to['django.views.static.serve']Note that if you skip a resolver it will also skip checks on everything inside that resolverNote: If using Django REST Framework'sDefaultRouterthen you need to turn offinclude_format_suffixes:router = routers.DefaultRouter(trailing_slash=True)
router.include_format_suffixes = False
router.register(r'myurl', MyViewSet)
urlpatterns += router.urlscheck_adminsChecks thatsettings.ADMINShas been properly set in settings files.check_git_hooksChecks that git hookshave been set up, one of:.git/hooksdirectory has been symlinked to the project'sgit-hookshuskyhooks have been installedcheck_db_constraintsChecks that all models that specifydb_constraintsin their Meta will generate unique constraint names when truncated by the database.CheckExplicitTableNamesChecks that all first-party models havedb_tableexplicitly defined on their Meta class, and the table name is in lowercaseArguments:enforce_lowercase- check that there are no uppercase characters in the table nameignore_labels- if an app label (egsilk) or app_label + model labels (egsilk.request)
matches something inignore_labelsthen it will be ignored.allianceutils.checks.DEFAULT_TABLE_NAME_CHECK_IGNOREcontains a default list of apps/models to ignoreCan be either astror a regex (anything that contains a.match()method)CheckReversibleFieldNamesChecks that all models have fields names that are reversible withunderscorize/camelize/camel_to_underscore/underscore_to_camelArguments:ignore_labels- ignore these apps/models: seeCheckExplicitTableNamesMiddlewareHttpAuthMiddlewareMiddleware to enable basic http auth to block unwanted traffic from search engines and random visitorsIntended to be used on dev / staging serversIs not a full authorization system: is a single hardcoded username/password and should be used on top of a proper authorization systemSetupAddallianceutils.middleware.HttpAuthMiddlewaretoMIDDLEWARE.AddHTTP_AUTH_USERNAMEandHTTP_AUTH_PASSWORDto appropriate setting file, e.g.settings/production_staging.pyRemember that you shouldn't be hardcoding credentials in code: read content from env vars or fileCurrentUserMiddlewareMiddleware to enable accessing the currently logged-in user without a request object.Assumes thatthreading.localis not shared between requests (an assumption also made by django internationalisation)SetupAddallianceutils.middleware.CurrentUserMiddlewaretoMIDDLEWARE.Usagefromallianceutils.middlewareimportCurrentUserMiddlewareuser=CurrentUserMiddleware.get_user()QueryCountMiddlewareWarns if query count reaches a given thresholdThreshold can be changed by settingsettings.QUERY_COUNT_WARNING_THRESHOLDUsageAddallianceutils.middleware.CurrentUserMiddlewaretoMIDDLEWARE.Uses thewarningsmodule to raise a warning; by default this is suppressed by djangoTo ensureQueryCountWarningis never suppressedwarnings.simplefilter('always',allianceutils.middleware.QueryCountWarning)To increase the query count limit for one request, you can callQueryCountMiddleware.increase_threshold(request, increment)To set the query count limit for one request you can callQueryCountMiddleware.set_threshold(request, threshold)Rather than hardcode a new limit,increase_threshold()is generally preferableThis can be useful to disable checks entirely (pass0as the new limit)defmy_view(request,*args,**kwargs):request.QUERY_COUNT_WARNING_THRESHOLD+=10...MigrationsRun SQL functionWrapper toRunSQLthat reads SQL from a file instead of inline in pythonThe reason you would do this as an external file & function is so that squashed migrations don't become unwieldy (django will inline and strip whitespace in the SQL)Usage:classMigration(migrations.Migration):# ...operations=[allianceutils.migrations.RunSQLFromFile('my_app','0001_intial.sql'),]ModelsUtility functions / classescombine_querysets_as_managerallianceutils.models.combine_querysets_as_manager(Iterable[Queryset]) -> ManagerReplacement for django_permanent.managers.MultiPassThroughManager which no longer works in django 1.8Returns a new Manager instance that passes through calls to multiple underlying queryset_classes via inheritanceNoDeleteModelA model that blocks deletes in djangoCan still be deleted with manual queriesRead django docs aboutmanager inheritanceIf you wish add your own manager, you need to combine the querysets:classMyModel(NoDeleteModel):objects=combine_querysets_as_manager(NoDeleteQuerySet,MyQuerySet)GenericUserProfileAllows you to iterate over aUsertable and have it return a correspondingProfilerecord without generating extra queriesMinimal example:# ------------------------------------------------------------------# base User model# If you're using django auth instead of authtools, you can just use# GenericUserProfileManager instead of having to make your own manager classclassUserManager(GenericUserProfileManagerMixin,authtools.models.UserManager):passclassUser(GenericUserProfile,authtools.models.AbstractEmailUser):objects=UserManager()profiles=UserManager(select_related_profiles=True)# these are the tables that should be select_related()/prefetch_related()# to minimise queriesrelated_profile_tables=['customerprofile','adminprofile',]# the default implementation will iterate through the related profile tables# and return the first profile it can find. If you have custom logic for# choosing the profile for a user then you can do that here## You would normally not access this directly but instead use the`.profile`# property that caches the return value of `get_profile()` and works# correctly for both user and profile recordsdefget_profile(self)->Model:# custom logicifdatetime.now()>datetime.date(2000,1,1):returnselfreturnsuper().get_profile()# ------------------------------------------------------------------# Custom user profilesclassCustomerProfile(User):customer_details=models.CharField(max_length=191)classAdminProfile(User):admin_details=models.CharField(max_length=191)# ------------------------------------------------------------------# Usage:# a list of User recordsusers=list(User.objects.all())# a list of Profile records: 1 query# If a user has no profile then you get the original User record backprofiles=list(User.profiles.all())# we can explicitly perform the transform on the querysetprofiles=list(User.objects.select_related_profiles().all())# joining to profile tables: 1 query# This assumes that RetailLocation.company.manager is a FK ref to the user table# The syntax is a bit different because we can't modify the query generation# in an unrelated tableqs=RetailLocation.objects.all()qs=User.objects.select_related_profiles(qs,'company__manager')location_managers=list((loc,loc.company.manager.profile)forlocinqs.all())There is also an authentication backend that will load profiles instead of just User recordsIf theUsermodel has noget_profile()method then this backend is equivalent to the built-in djangodjango.contrib.auth.backends.ModelBackend# ------------------------------------------------------------------# Profile authentication middlewareAUTH_USER_MODEL='my_site.User'AUTHENTICATION_BACKENDS=['allianceutils.auth.backends.ProfileModelBackend',]defmy_view(request):# standard django AuthenticationMiddleware will call the authentication backendprofile=request.userreturnHttpResponse('Current user is '+profile.username)Limitations:Profile iteration does not work with.values()or.values_list()raise_validation_errorsTheraise_validation_errorscontext manager enables cleaner code for constructing validationDjango documentationrecommends raising aValidationErrorwhen you encounter a problemThis creates a poor user experience if there are multiple errors: the user only sees the first error and has to resubmit a form multiple times to fix problemsraise_validation_errorsaccepts an (optional) function to wrapThe context manager returns aValidationErrorsubclass with anadd_errorfunction that follows the same rules asdjango.forms.forms.BaseForm.add_errorIf the wrapped function raises aValidationErrorthen this will be merged into theValidationErrorreturned by the context managerIf the wrapped function raises any other exception then this will not be intercepted and the context block will not be executedAt the end of a block,If code in the context block raised an exception (including aValidationError) then this will not be caughtIfValidationErrorthe context manager returned has any errors (either fromve.add_error()or from the wrapped function) then this will be raiseddef clean(self):
        with allianceutils.models.raise_validation_errors(super().clean) as ve:
            if some_condition:
                ve.add_error(None, 'model error message')
            if other_condition:
                ve.add_error('fieldname', 'field-specific error message')
            if other_condition:
                ve.add_error(None, {
                    'fieldname1': field-specific error message',
                    'fieldname2': field-specific error message',
                })
            if something_bad:
                raise RuntimeError('Oh no!') 
            
            # at the end of the context, ve will be raised if it contains any errors
            #   - unless an exception was raised in the block (RuntimeError example above) in which case
            #     the raised exception will take precedenceSometimes you already have functions that may raise aValidationErrorandadd_error()will not helpThecapture_validation_error()context manager solves this problemNote that due to the way context managers work, each potentialValidationErrorneeds its own withcapture_validation_errorcontextdef clean(self):
        with allianceutils.models.raise_validation_errors() as ve:
             with ve.capture_validation_error():
                 self.func1()
             with ve.capture_validation_error():
                 self.func2()
             with ve.capture_validation_error():
                 raise ValidationError('bad things')
            # all raised ValidationErrors will be collected, merged and raised at the end of this blockRulesUtility functions that return predicates for use withdjango-rulesfrom allianceutils.rules import has_any_perms, has_perms, has_perm

# requires at least 1 listed permission
rules.add_perm('northwind.publish_book', has_any_perms('northwind.is_book_author', 'northwind.is_book_editor'))

# requires listed permission
rules.add_perm('northwind.unpublish_book', has_perm('northwind.is_book_editor'))

# requires all listed permissions
rules.add_perm('northwind.sublicense_book', has_perms('northwind.is_book_editor', 'northwind.can_sign_contracts'))SerializersJSON OrderedA version of django's core json serializer that outputs field in sorted orderThe built-in one uses a standarddictwith completely unpredictable order which causes json diffs to show spurious changesSetupAdd toSERIALIZATION_MODULESin your settingsThis will allow you to do fixture dumps with--format json_orderedNote that django treats this as the file extension to useSERIALIZATION_MODULES={'json_ordered':'allianceutils.serializers.json_ordered',}Template Tagsrender_entry_pointReplaces old usage ofdjango-webpack-loaderAt time of writing django-webpack-loader does not have a stable release thatworks with webpack 4Worked at bundle level rather than entry point. See below for how we embed tags based on entry point.Reads JSON files generated byEntryPointBundleTrackerand embeds the required bundles in the pageWill output tags for all resources of the specified type. eg. Given JSON structure of:{"status":"done","entrypoints":{"admin":[{"name":"runtime.bundle.js","contentHash":"e2b781da02d36dad3aff"},{"name":"common.bundle.js","contentHash":"639269b921c8cf869c5f"},{"name":"common.bundle.css","contentHash":"d60a0fa36613ea58a23d"},{"name":"admin.bundle.js","contentHash":"c78fb252d4e00207afef"}]},"publicPath":"/assets/"}Output for{% render_entry_point 'admin' 'js' %}:<scripttype="text/javascript"src="/assets/runtime.bundle.js?e2b781da02d36dad3aff"></script><scripttype="text/javascript"src="/assets/common.bundle.js?639269b921c8cf869c5f"></script><scripttype="text/javascript"src="/assets/admin.bundle.js?c78fb252d4e00207afef"></script>Output for{% render_entry_point 'admin' 'css' %}:<linktype="text/css"href="/assets/common.bundle.css?d60a0fa36613ea58a23d"rel="stylesheet"/>As an entry point maps to a single HTML file it's expected you would only use this tag for a single entry point on a page but generally would call it for bothjsandcssArgumentsentry_point_name- Name of the entry point. This should match one of the entries to 'entry' in the webpack config.resource_type- The resource type to embed; eitherjsorcss.Optional Argumentsattrs- String representing extra attributes to pass to the HTML tagconfig='DEFAULT'- String index into the settingsWEBPACK_LOADERdict. Defaults to 'DEFAULT'.ConfigConfiguration can be specified via theWEBPACK_LOADERsetting. This is a dict indexed by the config name (defaults to 'DEFAULT')OptionsSTATS_FILE- the path to the stats file to readINCLUDE_QUERY_HASH- whether to include the content hash in the query string. Defaults totrue.BASE_URL- a URL to prepend to all chunks when rendered. This can be used when files are stored on a different host (eg. CDN).Example Usage{% load alliance_webpack %}<html><head>{% render_entry_point 'app' 'css' %}</head><body>...
  
  {% if DEBUG %}
    {# See https://reactjs.org/docs/cross-origin-errors.html #}
    {% render_entry_point entry_point 'js' attrs="crossorigin" %}
  {% else %}
    {% render_entry_point entry_point 'js' %}
  {% endif %}</body></html>default_valueSets default value(s) on the context in a templateThis is useful because some built-in django templates raise warnings about unset variables (egis_popupin the django admin template)Note that some tags (egwith) save & restore the context state; if done inside such a template tagdefault_valuewill not persist when the state is restored{% load default_value %}
{{ default_value myvar1=99 myvar2=myvar1|upper }}
{{ myvar1 }} {{ myvar2 }}Utiladd_autoreload_extra_filesAdds files to the autoreloader watch listWorks with both the built-inrunserverandrunserver_plusfromdjango-extensionsIfDEBUGis not enabled then this will do nothingThis should be called from inside theready()method of
anAppConfigclassMyAppConfig(AppConfig):defready(self):extra_files=["/data/file.csv",]add_autoreload_extra_files(extra_files)camelizeBetter version ofdjangorestframework-camel-caseDRF-CC camel cases every single key in the data tree.These functions allow you to indicate that certain keys are data, not field namestree={"first_name":"Mary","last_name":"Smith","servers":{"server_west.mycompany.com":{'user_accounts':{'mary_smith':{'home_dir':'/home/mary_smith',},},},},}# the keys at tree['servers'] and tree['servers']['serve_west.mycompany.com']['user_accounts'] will not be camel casedoutput_tree=allianceutils.util.camelize(tree,ignore=['servers','servers.*.user_accounts'])output_tree=={"firstName":"Mary","lastName":"Smith","servers":{"server_west.mycompany.com":{'userAccounts':{'mary_smith':{'home_dir':'/home/mary_smith',},},},},}allianceutils.util.camelize(data, ignores)- underscore case => camel case a json tree of dataallianceutils.util.underscorize(data, ignores)- camel case => underscore case a json tree of dataallianceutils.util.camel_to_underscore(str)- underscore case => camel case a stringallianceutils.util.underscore_to_camel(str)- camel case => underscore case a stringIt is assumed that words will not begin with numbers:zoo_foo99_baris okayzoo_foo_99barwill result in an irreversible transformation (zooFoo99bar=>zoo_foo99_bar)get_firstparty_appsutil.get_firstparty_appscan be used to retrieve app_configs considered to be first party, ie, all that does not come from a third party package.
This is beneficial when you want to write your own checks by excluding things you dont really care - a sample usage can be found inside 'checks.py', or
used as such:fromallianceutils.utilimportget_firstparty_appsapp_configs=get_firstparty_apps()models_to_be_checked={}forapp_configinapp_configs:models_to_be_checked.update({model._meta.label:modelformodelinapp_config.get_models()})python_to_django_date_formatConverts a pythonstrftime/strptimedatetime format string into adjango template/PHPdate format stringCodes with no equivalent will be droppedExample:allianceutils.util.date.python_to_django_date_format("%Y%m%d%H%M%S")# returns "Ymd His"retry_fnRepeatedly (up to a hard limit) call specified function while it raises specified exception types or until it returnsfromallianceutils.utilimportretry_fn# Generate next number in sequence for a model# Number here has unique constraint resulting in IntegrityError being thrown# whenever a duplicate is added. can be useful for working around mysql's lack# of sequencesdefgenerate_number():qs=MyModel.objects.aggregate(last_number=Max(F('card_number')))next_number=(qs.get('last_card_number')or0)+1self.card_number=card_numbersuper().save(*args,**kwargs)retry_fn(generate_number,(IntegrityError,),10)ExperimentalThese are experimental and may change without noticedocument_reverse_accessorsmanagement commandChangelogSeeCHANGELOG.mdDevelopmentTo create a clean local environmentpython3 -m venv venv && source venv/bin/activate && pip install --upgrade pippoetry install --no-root --sync --only=main --extras=""Note that due toa poetry bugextras are currently not removedThis will install the latest django version; if you want to test a specific django version you need topip installit manuallyDev dependenciespoetry install --no-root --sync --with=dev --extras "extras mysql postgres"Release ProcessPoetry ConfigAdd test repositorypoetry config repositories.testpypi https://test.pypi.org/legacy/Generate an account API token athttps://test.pypi.org/manage/account/token/poetry config pypi-token.testpypi ${TOKEN}On macs this will be stored in theloginkeychain atpoetry-repository-testpypiMain pypi repositoryGenerate an account API token athttps://pypi.org/manage/account/token/poetry config pypi-token.pypi ${TOKEN}On macs this will be stored in theloginkeychain atpoetry-repository-pypiPublishing a New Release* Update CHANGELOG.md with details of changes and new version
* Run `bin/build.py`. This will extract version from CHANGELOG.md, bump version in `pyproject.toml` and generate a build for publishing
* Tag with new version and update the version branch:
    * `ver=$( poetry version --short ) && echo "Version: $ver"`
    * `git tag v/$ver`
    * `git push --tags`
* To publish to test.pypi.org
    * `poetry publish --repository testpypi`
* To publish to pypi.org
    * `poetry publish`TestingTo run test casesThe django settings module istest_allianceutils/settings.pyThe following env vars are optional but you may want to set them if the default don't match your local setup:DB_NAMEDB_HOSTDB_PORTDB_USERDB_PASSWORDtoxused to run tests against different django/python/database versionstoxto run all tests. Will require that you have a postgres & mysql server running.tox -f django42will run the subset of tests that cover django 4.2. Checktox.inifor the list of tested environments.When you push to github agithub Actionsworkflow will be triggered (see.github/workflows/django.yml)TODO:mypy checks all passgithub actions pass[] stricter mypy settings?[] flake8, black, isortcan cachetools replace @method_cache?No; can use methodtools but is another dependency. just documented the alternativeUse collections.abc:no, doesn't work with 3.8Use | instead of Optional, Unionno, doesn't work with 3.8[] Remove legacy build settings (poetry shouldn't need setup.cfg, MANIFEST.in so can we remove them?)