Code Monkey home page Code Monkey logo

django-service-objects's Introduction

django-service-objects Latest Version

Build Status Python Support PyPI - Django Version License

Service objects for Django

What?

This is a small library providing a Service base class to derive your service objects from. What are service objects? You can read more about the whys and hows in this blog post, but for the most part, it encapsulates your business logic, decoupling it from your views and model methods. Put your business logic in service objects.

Installation guide

Install from pypi:

pip install django-service-objects

Add service_objects to your INSTALLED_APPS:

# settings.py

INSTALLED_APPS = (
    ...
    'service_objects',
    ...
)

Example

Let's say you want to register new users. You could make a CreateUser service.

from django import forms

from service_objects.services import Service

class CreateUser(Service):
    email = forms.EmailField()
    password = forms.CharField(max_length=255)
    subscribe_to_newsletter = forms.BooleanField(required=False)

    def process(self):
        email = self.cleaned_data['email']
        password = self.cleaned_data['password']
        subscribe_to_newsletter = self.cleaned_data['subscribe_to_newsletter']

        self.user = User.objects.create_user(username=email, email=email, password=password)
        self.subscribe_to_newsletter = subscribe_to_newsletter

        if self.subscribe_to_newsletter:
            newsletter = Newsletter.objects.get()
            newsletter.subscribers.add(self.user)
            newsletter.save()
            
        return self.user
    
    def post_process(self):
        WelcomeEmail.send(self.user, is_subscribed=self.subsribe_to_newsletter)
        
        # Calling a celery task after successfully creating the user.
        create_billing_account.delay(self.user.id)

Notice that it's basically a Django form but with a process method. This method gets called when you call execute() on the process. If your inputs are invalid, it raises InvalidInputsError.

The newly added post_process can also be included for running extra tasks that need to be executed after the service completes.

Here's how you use it:

CreateUser.execute({
    'email': '[email protected]',
    'password': 'doorsofstone',
    'subscribe_to_newsletter': True,
})

Now you can use it anywhere.

In your views

# views.py

# Function Based View
def create_user_view(request):
    form = NewUserForm()
    if request.method == 'POST':
        form = NewUserForm(request.POST)

        if form.is_valid():
            try:
                CreateUser.execute(request.POST)
                return redirect('/success/')
            except Exception:
                form.add_error(None, 'Something went wrong')

    return render(request, 'registration/new-user.html', {'form': form})


# Class Based View
class CreateUserView(ServiceView):
    form_class = NewUserForm
    service_class = CreateUser
    template_name = 'registration/new-user.html'
    success_url = '/success/'

A management command

# management/commands/create_user.py

class Command(BaseCommand):
    help = "Creates a new user"

    def add_arguments(self, parser):
        parser.add_argument('email')
        parser.add_argument('password')

    def handle(self, *args, **options):
        user = CreateUser.execute(options)
        self.stdout.write(f'New user created : {user.email}')

In your tests

class CreateUserTest(TestCase):

    def test_create_user(self):
        inputs = {
            'email': '[email protected]',
            'password': 'do0r$0f$stone42',
            'subscribe_to_newsletter': True,
        }

        CreateUser.execute(inputs)

        user = User.objects.get()
        self.assertEqual(user.email, inputs['email'])

        newsletter = Newsletter.objects.get()
        self.assertIn(user, newsletter.subscribers.all())

And anywhere you want. You can even execute services inside other services. The possibilities are endless!

Documentation

Docs can be found on readthedocs.

If you have any questions about service objects, you can tweet me @mixxorz.

django-service-objects's People

Contributors

andreas-bergstrom avatar angelonfira avatar c17r avatar jackton1 avatar joeydebreuk avatar junhua avatar mixxorz avatar mnemchinov avatar murdocb avatar peterdekkers avatar peterfarrell avatar taobojlen avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

django-service-objects's Issues

Add ModelField that accepts model instances instead of PKs

Currently, if you wanted to accept model instances as inputs to your services, you'd have to use ModelChoiceField. It works well enough, but it has its quirks. It would be a good idea to a have a custom field that can accept model instances directly. This would fix a few issues I've found when using ModelChoiceField.

Prevent unintended model mixups

Because ModelChoiceField just validates using the primary key, it's really easy to mix up models.

person = Person.objects.get(pk=1)
tenant = Tenant.objects.get(pk=1)

SendInvoice.execute({'person': tenant.pk})
# meant to pass in person.pk, but because it's just an integer, it still validates

If we pass in model instances, we can check for its type.

person = Person.objects.get(pk=1)
tenant = Tenant.objects.get(pk=1)

SendInvoice.execute({'person': tenant})
# raises InvalidInputsError, person is not an instance of Person

Allow unsaved model instances as inputs

Because ModelChoiceField fetches your model via its PK, it inherently requires that your model already be saved before being passed in.

With a custom field, you could accept unsaved model instances as inputs, as long as they're valid.

person = Person(
    name='Mitch',
    age=24,
)

SendInvoice.execute({'person': person})
# works

person = Person(
    name=1234,
)

SendInvoice.execute({'person': person})
# raises InvalidInputsError (name should be string, age is required)

Prevent multiple calls to the database

Often I find that I first need to fetch a model instance before I can pass them as inputs. This means that two database queries are made to fetch the same data.

person = Person.objects.get(name='Mitch')  # fetch object from the database

SendInvoice.execute({'person': person.pk})  # the form would have to fetch the data from the database again

Solution: ModelField

I feel like it would be relatively straightforward to implement a custom field that accepts a model instance as the parameter.

How I imagine the API to be:

class SendInvoice(Service):
    person = ModelField('people.Person', allow_unsaved=True)

# to use
person = Person.objects.get(name='Mitch')

SendInvoice.execute({'person': person})

If someone could help with this, that'd be great.

MultiModelField fails when validating large Querysets

When using large Querysets as the input to MultipleModelField, the service fails (usually to out of memory / timeouts). This occurs because the Queryset is evaluated in clean as the MultipleModelField tries to evaluate that all items in the iteration are of type app.Thing.

This breaks even if you are handling large querysets with chunking in the process method.

class DoSomethingToThings(Service):
    things = MultipleModelField("app.Thing", required=True)

    def process(self):
        for thing in Model.objects.all().iterator(chunk_size=2000):
            pass  # Do something

Not sure how to handle this in MultipleModelField as iterable datatypes like list and tuples in addition to Django Querysets. Also, it has an unsaved check for model objects.

Propose a QuerysetField that accept Django Querysets of a certain type only. I will make a PR.

The post_process() method is skipped if `db_transaction = False`

If you set db_transaction = False on your service and implement a post_process() method, the post process method does not execute. While examining the code, it makes sense why because the on transaction commit hook is being used. However, the behavior is a bit obtuse especially if you are not familiar with the internals of the library.

The solution I propose is if db_transaction = False and a post_process() is implemented, then automatically execute the post_process() in sequence after the process(). This would be useful for services where transaction safety is not needed (like an API call, etc.) and would make the feature in parity when db_transaction = True.

I do believe it's as easy as implementing a finally statement:

    @contextmanager
    def _process_context(self):
        """
        Returns the context for :meth:`process`
        :return:
        """
        if self.db_transaction:
            with transaction.atomic(using=self.using):
                if self.has_on_commit_action:
                    transaction.on_commit(self.post_process)
                yield
        else:
            try:
                yield
            finally:
                if self.has_on_commit_action:
                    self.post_process()

Note: I would likely rename has_on_commit_action to has_post_process_action since it would not be transaction related.

Alternatively, we could throw an exception if you define a post_process when transaction is false however I think making the feature act in parity is better.

Please let me know your thoughts and I'd be happy to make a PR.

Add support for validating None declared fields.

The services shouldn't allow more fields than expected when calling execute

class MyService(Service):
	field_a = forms.CharField(...)

Call

MyService.execute({ 'field_y': 1, 'field_a'; 2 })

Expectations:
Raises an exception

'Invalid field provided "field_y": Please provide one of the following fields: ({...})'

0.3.0 process is not run in a transaction

Release 0.3.0 added a new db_transaction flag which controls whether or not process will be called inside a transaction. Unfortunately, it was incorrectly implemented meaning that by default, process wasn't called inside a transaction.

Double validation ?

Hi, I have a question, when you call the service say inside a view just like the example you gave, there's going to be a double validation right ? I mean, one from the form itself, and again from the service because its based on forms right ? Is this OK? Or will a feel a delay ? I copy the example from README for making myself clear. Thanks in advance.

# views.py

# Function Based View
def create_user_view(request):
    form = NewUserForm()
    if request.method == 'POST':
        form = NewUserForm(request.POST)

        if form.is_valid():
            try:
                CreateUser.execute(request.POST)
                return redirect('/success/')
            except Exception:
                form.add_error(None, 'Something went wrong')

    return render(request, 'registration/new-user.html', {'form': form})


# Class Based View
class CreateUserView(ServiceView):
    form_class = NewUserForm
    service_class = CreateUser
    template_name = 'registration/new-user.html'
    success_url = '/success/'

Import Errors with services.py

Hello,

when I try to import a model at the top of my services.py module I get the following ImportError:

...
django_1    |   File "/app/server/stations/models.py", line 9, in <module>
django_1    |     from .services import UpdateAlarmSensorsInDatabase
django_1    |   File "/app/server/stations/services.py", line 3, in <module>
django_1    |     from .models import BitSensorInformation
django_1    | ImportError: cannot import name 'BitSensorInformation'

services.py:

from service_objects.services import Service
from .models import BitSensorInformation  # causes the error

class UpdateAlarmSensorsInDatabase(Service):
 
     def process(self):
         # from .models import BitSensorInformation  # this would work
         print(BitSensorInformation.objects.all())

models.py:

from .services import UpdateAlarmSensorsInDatabase

class BitSensorInformation(models.Model):
    pass

class AnotherModel(models.Model):

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        UpdateAlarmSensorsInDatabase.execute({})

As you can see I need to execute the service object within the model save method. This leads to a cross import issue. Is there any advice you could give me? I try to avoid importing the model at function level.

Custom cleaning of inputs

Have you all thought about adding the possibility for custom cleaning?

Or maybe some documentation about overriding service_clean?

I'd be happy to contribute if you all think either of those options are good ideas!

Love the repo by the way! Big fan of the philosophy.

How to use it with nested models for creation ?

class Sale(models.model):
invoice_number = models.CharField(....)

class SaleItem(models.Model):
sale = models.ForeignKey(Sale)
....

How can i use a service for creating a sale with its respective items ? I'll much appreciate the way you would do it.

MultipleModelField should accept other types as iterable not just list

It seems to me that MultipleModelField should accept any iterable such as a Django queryset, tuple, and set -- not just a list.

I commonly have forms using forms.ModelMultipleChoiceField which in the cleaned_data returns a queryset. I am having to wrap list around the data from a forms.ModelMultipleChoiceField to coerce it into a list for the library.

Views.py:

DuplicateMergeService.execute({
    'duplicates':list(form.cleaned_data['duplicates']),
    'by': request.user
})

Seems like MultipleModelField should accept any iterable and not be restricted to list

Optionally execute without atomic transaction?

Hello! I'm currently handling a case where if a service's execution fails I want to be left with a record of the failure in the database. This means I'm overriding the execute class method on Service to remove the following line:

with transaction.atomic():

I wonder if transactionality could be a configurable option on the Service class?

Add extensive support for DRF

A think your concept is really what default Django is missing. In fact, in serveral business-heavy django apps we have used something like your implementation. I think it should be very useful to support DRF serializers (instead of django forms), integrating with update, create methods.

Service for model

Hi,
I started using your logic and it helped me a lot!
One thing I thought could be interesting would be create another class of Service which inherits from Modelform. It would get all the fields and validations from the model.
I will fork your repo and try working on it. When it's ready if you like i could send a pr.
Thanks again!

Documentation for included fields

Currently, the three fields included in django-service-objects are only documented through their docstrings. It would be good to put usage examples of these fields in order to show people their utility.

Fields:

  • ModelField
  • MultipleFormField
  • MultipleModelField

Documentation

At first I thought this project was small enough to not need Sphinx documentation.

That's starting to not be the case.

We need to document:

  • Basic stuff
  • Service class
  • MultipleFormField
  • ServiceView
  • Examples of usage
  • Philosophy

Things like that.

Usage with uploaded files

Any help on how to use this library with uploaded files? I have it working for where I set the service object to normal Django form fields like forms.CharField (where the input data comes from a DRF serializers validated_data).

I've tried using form.FileField, where the input object is a TemporaryUploadedFile, but it keep raising the following exception when trying to clean the service object,

service_objects.errors.InvalidInputsError: ({'uploaded_file': ['This field is required.']}, [])

DRY defining form?!?

I've just noticed this project now ;)

The example in the docs (and the README) defines a Service class with form fields and it seems that i have to create a separate form class, isn't it?

So the form fields are defined two times. It's against the DRY concept...

I can see the need for it. For example, if a view form creates several model instances at the same time.

However, this is too costly for simple cases.
Am I missing something?

ExceptionHandler Mapper

Hi @mixxorz what do you think about the idea of having an ExceptionHandler class that could map the InvalidInputErrors to another type of exceptions, for instance, mapping to a relevant error for django rest framework?

MultipleModelField and ListField don't allow empty lists

We've been using this library for a few weeks and we're really liking it so far -- thank you!

There's one thing that keeps tripping us up: the MultipleModelField and ListFields (and maybe others) fail their validation if you pass an empty list. This is counterintuitive -- an empty list is still a list! You can work around it by setting required=False on the field, but then there's an ambiguity around whether the field is [] or None.

Would you be open to a PR that changed this behaviour such that empty lists are allowed?

cannot import name 'six' from 'django.utils'

I Looked that code(service_objects/services.py), this problem solved but i'm getting same error.
Could you please help my why after install latest version its not working?

My assumption: Code updated according to djagno3 but new build not updated on pip packages.

Please help me.
Thank you so much.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.