I have this Django project that I'm working on, which won't allow users to select an entry (User entries) on the raw_id_fields popup, if they don't have change permissions (which they can't have at all). That's really weird cause that doesn't happen with the select tag list if I remove the raw_id_fields attribute on my ModelForm class at admins.py.
How can this permission behavior be consistent if it changes according to a different interface setting? I mean, user only have permission to select users on the form if they are displayed as a select tag. It seems to me that it's a big consistency failure with the way Django permissions was designed, which, in my opinion, should have native can_view permission, in addition to can_|add, change, delete.
While googling around I found a few topics discussing this matter, but all of them end up with some really painful solutions that don't seem straightforward to me. I wonder if something so simple could have a straightforward solution that won't require lots of workarounds.
Here is an example that looks like my actual code:
models.py
class Project(models.Model):
manager = models.ForeignKey(User)
...
admins.py
class ProjectAdmin(admin.ModelAdmin):
raw_id_fields = ['manager',]
...
As you've said, this is really weird. Thats why I've opted to "extend" the django admin to my specific requirements sometimes.
The easiest way to meet this goal is by overriding the has_change_permission of the referenced ModelAdmin
As you have the request object as an argument of the method, you can evaluate:
if the request comes from a raw_id_field or not
if the user has permissions to see that models or not
any other constraint you have
A simple prototype for the method:
def has_change_permission(self, request, obj=None):
if obj is None and '_popup' in request.GET:
return True
return super(MyAdmin, self).has_change_permission(request, obj)
Related
I have a project using Django 2.2.4.
I have a Django model called Company.
I use a post_save signal to make sure that as soon as a new Company is created, a new model instance called "Billing" is created, which is associated with that Company. This contains the company's billing information. This works great.
Since my Billing object is associated with a Company, and I use on_delete=models.CASCADE, as soon as the Company is deleted, the Billing object associated with that company is automatically deleted as well. This works great too.
Since the Billing object for each company is now automatically created and deleted along with the Company, there's no need for admins using the Django Admin web interface to ever have to manually create, or delete Billing objects. I want to hide this functionality from them.
Normally, the common way to prevent Django Admin from allowing someone to add or delete an object is by adding this to that model's ModelAdmin in admin.py:
class BillingAdmin(admin.ModelAdmin):
...
# Prevent deletion from admin portal
def has_delete_permission(self, request, obj=None):
return False
# Prevent adding from admin portal
def has_add_permission(self, request, obj=None):
return False
This works, and does indeed hide the ability for Admins to create or delete instances of the Billing object manually. It does however have one negative side-effect: Django Admin users can no longer delete a company. When deleting a company, Django does a lookup for all associated objects that need to also be deleted, notices that the user isn't allowed to delete the associated Billing object, and prevents the user from deleting the Company.
While I don't want Django Admin users to be able to manually create or delete instances of the Billing model, I still want them to be able to delete an entire Company, which will result in the deletion of the instance of the Billing model associated with that Company.
In my case, preventing users from deleting an instance of the Billing model isn't so much a security feature, as it is intended to prevent confusion, by not letting the database end up in a state where a Company exists, but no billing object exists for it. Django would obviously not have a problem with this, but it would confuse users.
Is there a workaround for this?
Update:
With the has_delete_permission set, if you try to delete a company through Django Admin, you get this:
No exceptions are thrown. At least none that aren't caught, and appear in the Django logs.
My Models look like this:
class Company(Group):
...
class Billing(models.Model):
company = AutoOneToOneField('Company', on_delete=models.CASCADE, blank=False, null=False, related_name="billing")
monthly_rate = models.DecimalField(max_digits=10, decimal_places=2, default=0, blank=False, null=False)
# Create billing object for a company when it is first created
#receiver(post_save, sender=Company)
def create_billing_for_company(sender, instance, created, *args, **kwargs):
if created:
Billing.objects.create(company=instance)
The AutoOneToOneField is part of django-annoying. It makes sure that if you run MyCompany.billing, and an associated billing object doesn't exist yet, one will be created automatically, rather than an exception being raised. May not be required here, since I automatically create the object when the company is created, but it can't hurt, and ensures my code never needs to worry about the associated object not existing.
Also note that I have NOT overridden my Billing model's delete function.
Another option is to override specifically designed for this method get_deleted_objects in main Company ModelAdmin - to allow deleting all related objects when deleting company from admin web.
class CompanyAdmin(admin.ModelAdmin):
def get_deleted_objects(self, objs, request):
"""
Allow deleting related objects if their model is present in admin_site
and user does not have permissions to delete them from admin web
"""
deleted_objects, model_count, perms_needed, protected = \
super().get_deleted_objects(objs, request)
return deleted_objects, model_count, set(), protected
Here we replace perms_needed with empty set() - which is a set of permissions user failed to satisfy for deleting related objects via admin site.
When deleting objects via django admin it:
checks if user has permissions to delete main object
calculates list of other related objects that should be deleted as well
for these related objects if their model is registered in admin_site, django performs additional permission check
if the user has admin site permissions to delete these related objects as well
if user does not have permissions to delete related objects - these required permissions are added to list and shown as error page
To get the list of related objects to be deleted with main one utility method is used - get_deleted_objects
And since Django 2.1 there is more comfortable way to override it directly from ModelAdmin instance:
get_deleted_objects
After a little digging, it does appear that the ModelAdmin will simply call delete() on the object, meaning that it shouldn't look at your billing permissions for admin specifically. Looking at the model delete also confirms that it does not care what the admin permissions are.
I got curious, and wondered if maybe the has_delete_permission function looks at related objects. That also didn't appear to be the case. At this point, I'm curious if you have overridden your Billing model's delete function? That would prevent deletion, and if you have CASCADE set as your on_delete for the relation, it would not allow you to finish deleting the Company at that point because it was unable to cascade delete.
If you have a stack trace or explicit error message, please share it.
With that said, I don't know if I agree with the approach to this. I think it would make more sense to enforce this at the model level of Billing. When attempting a delete, you could check if there are no other Billing objects for the Company, and if so, raise a validation error notifying the user that a Company must have at least one Billing. I don't know your models since they aren't posted, so if it's a one-to-one relation, please ignore this. Here's a rough idea of how I would expect it to look otherwise:
def delete(self):
other_billing = Billing.objects.filter(company_id=self.company.id).exclude(id=self.id).first()
if not other_billing:
raise ValidationError({"message": "A company must have at least one Billing."})
super().delete()
Edit: Here's a method using ModelAdmin.delete_model() which won't raise an exception.
def delete_model(self, request, billing):
other_billing = Billing.objects.filter(company_id=billing.company.id).exclude(id=billing.id).first()
if not other_billing:
# from django.contrib import messages
messages.error(request, "A company must have at least one Billing.")
else:
super().delete_model(request, billing)
EDIT: I did find that you have access to the request, which seems to be the only reliable way via has_delete_permissions() to check whether you're on the admin change page for your model or not. For the record I think this way is hacky and I don't recommend it. However, it would allow for cascading deletes while not allowing delete via the change page (it will hide the button):
def has_delete_permissions(self, request, obj=None):
# If we have an object, it's been fetched for deletion or to check permission against it.
if isinstance(obj, Billing):
if request.path == reverse("admin:<APP_NAME>_billing_change", args=[obj.id]):
return False
return True
I am sure that this is fairly straightforward, but I have scoured the documentation and I can't quite figure out how to do this.
I have extended my User class to have two ManyToMany relationships to other users: trainers and teammates.
If a user owns an object (defined by a user ForeignKey on the model), then that user should be able to GET, POST, PUT, PATCH, and DELETE. I have set up these endpoints with ModelViewSet. If a user is a trainer of the owner, they should have the same privileges. If a user is a teammate of the owner, they should only be able to GET.
In a list view of these objects, a user should only see the objects they own and the objects where they are a trainer or teammate of the owner. If they try and access a detail view of an object where they are not the friend or the teammate of the owner, it should return a 403.
I extended BasePermission as follows to try and create this behavior -- I then added it to the ModelViewSet where I wanted this behavior.
class TrainerAndOwnerOrTeammate(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
user = request.user
owner = obj.user
if user == owner:
return True
if user in owner.trainers.all():
return True
if user in owner.teammates.all():
return request.method in permissions.SAFE_METHODS
return False
Since the REST Framework documentation specifies that this isn't run on a per-object basis for list views, I overrode get_queryset to filter by the request user.
The issue is now I get a 404 error, not a 403, if I try and access a detail view I shouldn't have access to. I understand why that's happening, but is there a way to fix it?
I think one solution is to drop get_queryset() and define a custom filter. Filter classes let you filter the queryset based on the request and view being accessed. Another way is to split your viewset into multiple individual views. Another way is to define get_object.
I ended up overriding the list method of the ModelViewSet and keeping the permission class. The permission class handled the detail views and actions, and the list method handled the list view.
I'm using Django 1.9 on my site and I need an effective way of completely ignore the inactive users, so I don't send them any notifications, emails, etc.
I've tried using a custom Model Manager that only returns the active ones, like this:
class ActiveAccountsManager(models.Manager):
use_for_related_fields = True
def get_queryset(self):
return super(ActiveAccountsManager, self).get_queryset().filter(user__is_active=True)
class Account(models.Model):
class Meta:
verbose_name = _('Account')
verbose_name_plural = _('Accounts')
objects = ActiveAccountsManager()
all_accounts = models.Manager() # Enabling the obtention of all the users, instead of only the active ones
user = models.OneToOneField(User)
type = models.IntegerField(choices=ACCOUNT_TYPES, default=-1)
And, while it works exactly as I want when I directly try to query Account objects, it doesn't when they are referenced through an object that has a ForeignKey relation with it. For example, if I had a Comment model like the following one:
class Comment(models.Model):
author = models.ForeignKey(Account)
I'd like that, when I query for Comment objects, the ones whose author is a inactive user (i.e. an user that the default Manager of the Account model won't return) aren't returned either, instead of the current behavior that returns the comment but says that the account related to it does not exist
Is there any way of achieving this without specifically defining a custom ModelManager on the Comment class? Because the thing is that I want to achieve this in a lot of classes related to the Account one and I can't think of a way of doing it without being extremely repetitive.
Please, don't hesitate to ask if any part of my question isn't clear enough or if I need to provide more details about my code. Thank you so much in advance.
It should already work in your case, as the docs (https://docs.djangoproject.com/en/1.9/topics/db/managers/#default-managers) say:
Default managers
If you use custom Manager objects, take note that the first Manager
Django encounters (in the order in which they’re defined in the model)
has a special status. Django interprets the first Manager defined in a
class as the “default” Manager, and several parts of Django (including
dumpdata) will use that Manager exclusively for that model. As a
result, it’s a good idea to be careful in your choice of default
manager in order to avoid a situation where overriding get_queryset()
results in an inability to retrieve objects you’d like to work with.
Since the ActiveAccountsManager declaration comes first in your model, it should be already marked as the "default" one.
If you were to update to Django >= 1.10, you can set up explicitly the default manager for your model (see https://docs.djangoproject.com/en/1.11/ref/models/options/#default-manager-name).
So every model comes with some commonly used functions such as save and delete.
Delete is often overridden to set a boolean field such as is_active to false, this way data is not lost. But sometimes a model exists that has information that, once created, should always exist and never even be "inactive". I was wondering what the best practice for handling this model's delete method would be?
ideas
make it simply useless:
def delete(self):
return False
but that just seems odd. Is there maybe a Meta option to disable deleting? is there any "nice" way to do this?
Well it depends, you cannot truly restrict deletion, because somebody can always call delete() on queryset or just plain DELETE sql command. If you want to disable delete button in django admin though, you should look here.
delete() on queryset can be restricted with this:
class NoDeleteQuerySet(models.QuerySet):
def delete(self, *args, **kwargs):
pass
class MyModel(models.Model):
objects = NoDeleteQuerySet.as_manager()
...
Django docs - link
In my django admin section, I'd like to show different versions of the admin page depending on what kind of user is currently logged in. I can think of a couple ways this might work, but haven't figured out how to do any of them.
Perhaps I could put logic into the admin.ModelAdmin to look at the current user and change the 'exclude' field dynamically. Does that work? Or maybe run different custom templates based on who's logged in, and have the templates include / exclude the fields as appropriate.
I could register two versions of the admin.ModelAdmin class, one for each type of user, and maybe restrict access through permissions? But the permissions system seems to believe fairly deeply in one set of permissions per model class so I'm not sure how to change that.
I could grab a couple of the widgets that are used in rendering the admin page templates, and include them in my own page that does the one specific job I need powerful users to be able to do.
I could set up multiple AdminSites and restrict access to them through the url / view system. But then I'm not sure how to register different admin.ModelAdmin classes with the different AdminSites.
Any advice on this would be appreciated.
Answer
Thanks for the hint. Here's how I did it...
def get_form(self, request, obj=None, **kwargs):
"""This dynamically inserts the "owners" field into the exclude list
if the current user is not superuser.
"""
if not request.user.is_superuser:
if self.exclude:
self.exclude.append('owners')
else:
self.exclude = ['owners']
else:
# Necessary since Admin objects outlive requests
try:
self.exclude.remove('owners')
except:
pass
return super(OwnersModelAdmin,self).get_form(request, obj=None, **kwargs)
There are quite a few hooks provided in the ModelAdmin class for this sort of thing.
One possibility would be to override the get_form method. This takes the request, as well as the object being edited, so you could get the current user from there, and return different ModelForms dependent on the user.
It's worth looking at the source for ModelAdmin - it's in django.contrib.admin.options - to see if overriding this or any other other methods might meet your needs.