Merge lp:~salgado/offspring/private-projects into lp:offspring

Proposed by Guilherme Salgado
Status: Merged
Merged at revision: 90
Proposed branch: lp:~salgado/offspring/private-projects
Merge into: lp:offspring
Diff against target: 719 lines (+490/-18)
11 files modified
Makefile (+4/-1)
lib/offspring/web/queuemanager/models.py (+137/-14)
lib/offspring/web/queuemanager/sql/project.postgresql_psycopg2.sql (+5/-0)
lib/offspring/web/queuemanager/tests/__init__.py (+1/-0)
lib/offspring/web/queuemanager/tests/factory.py (+23/-2)
lib/offspring/web/queuemanager/tests/test_models.py (+306/-0)
lib/offspring/web/queuemanager/views.py (+1/-0)
lib/offspring/web/settings.py (+1/-0)
lib/offspring/web/templates/queuemanager/projectgroup_details.html (+1/-1)
migration/001-project-privacy.sql (+10/-0)
requirements/requirements.web.txt (+1/-0)
To merge this branch: bzr merge lp:~salgado/offspring/private-projects
Reviewer Review Type Date Requested Status
Kevin McDermott Approve
Offspring Committers Pending
Review via email: mp+83947@code.launchpad.net

This proposal supersedes a proposal from 2011-11-18.

Description of the change

Add support for private projects on models of web.queuemanager

It adds a ._is_private attribute to Project, together with a few helper methods to decide who's able to see a given private project and to query only Projects a given user is allowed to see. This also works for classes that have a foreign key (direct or indirect) to Project, as we defer the checks to the class that provides the ._is_private attribute.

The default model manager (.objects) now returns only public objects, to reduce the risk of leaking private data. Subsequent branches will update all the callsites to use the privacy-aware API (.all_objects.accessible_by_user(user)) to lookup objects. This code, together with the callsite updates, is already running on offspring.linaro.org

In order to try this you'll need to get http://pypi.python.org/pypi/django-group-access and place it on your .download-cache/

To post a comment you must log in.
48. By Guilherme Salgado

merge trunk

Revision history for this message
Kevin McDermott (bigkevmcd) wrote :
Download full text (3.5 KiB)

#1

+BUILDRESULTS_DIRECTORY = '/srv/offspring.com/www/builds/'

Is this the right path?

#2

Where is the migration used?

#3

+ def test_get_projects_filters_private_objects(self):
No docstring.

#4

+class ReleaseTests(
+ TestCase, ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
+ model = Release
+
+ def factoryMethod(self, project):
+ return factory.make_release(
+ build=factory.make_build_result(project=project))
+
+
+class LexbuilderTests(
+ TestCase, ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
+ model = Lexbuilder
+
+ def factoryMethod(self, project):
+ return factory.make_lexbuilder(
+ current_job=factory.make_build_result(project=project))
+
+
+class BuildResultTests(
+ TestCase, ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
+ model = BuildResult
+ factoryMethod = factory.make_build_result
+
+
+class BuildRequestTests(
+ TestCase, ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
+ model = BuildRequest
+ factoryMethod = factory.make_build_request

Couldn't this be done as a parent class, something like BasePrivateModelTestCase or something?

#5

+class ProjectTests(TestCase):

There are no docstrings explaining any of the tests...

#6

+from .test_models import *

Any idea why we actually need this? (I know we do, but is there a way we can drop it?) My concern is that missing tests from there won't necessarily affect localised test runs, but could be missed from just running test-web.

Also...see PEP8

    - Relative imports for intra-package imports are highly discouraged.
      Always use the absolute package path for all imports.
      Even now that PEP 328 [7] is fully implemented in Python 2.5,
      its style of explicit relative imports is actively discouraged;
      absolute imports are more portable and usually more readable.

#7

=== added directory 'lib/offspring/web/queuemanager/sql'
=== added file 'lib/offspring/web/queuemanager/sql/project.postgresql_psycopg2.sql'

What are these used for? Is there a speed benefit from using sqlite in tests?

Personally, I'd prefer to run the tests against the database we use in production i.e. postgresql and not have SQL files in separate directories for this purpose...

#8

+class Release(models.Model, MaybePrivateMixin):
+class BuildResult(models.Model, MaybePrivateMixin):
+class Lexbuilder(models.Model, MaybePrivateMixin):

+ # Using PublicOnlyObjectsManager here we ensure that any oversights when
+ # updating existing uses of Release.objects won't leak anything related to
+ # private projects.

How about defining a base class for these MaybePrivateMixin objects rather than redeclaring he same thing and comments each time, and just subclass for the various models that use it?

#9

+ hostname = os.popen('hostname').read().strip()
+ config.set('DEFAULT', 'hostname', hostname)
+ config.set('DEFAULT', 'username', os.environ['USERNAME'])

I reimplemented this in add-path-independence, but note that you can use os.uname to get the hostname...

#10

+base_dir: /srv

-development_pool: %(base_dir)s/builds/
-production_pool: %...

Read more...

review: Approve
Revision history for this message
Kevin McDermott (bigkevmcd) :
review: Needs Fixing
Revision history for this message
Guilherme Salgado (salgado) wrote :
Download full text (5.5 KiB)

On Wed, 2011-11-30 at 14:57 +0000, Kevin McDermott wrote:
> Review: Approve
>
> #1
>
> +BUILDRESULTS_DIRECTORY = '/srv/offspring.com/www/builds/'
>
> Is this the right path?

This is part of path-independence; I've already removed it from this
branch but it looks like you merged an old revision when you reviewed.

>
> #2
>
> Where is the migration used?

It's not; Offspring doesn't use south (Cody had concerns with it) so the
migration scripts have to be applied manually.

>
> #3
>
> + def test_get_projects_filters_private_objects(self):
> No docstring.

Done

> #4
>
> +class ReleaseTests(
> + TestCase,
> ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
> + model = Release
> +
> + def factoryMethod(self, project):
> + return factory.make_release(
> + build=factory.make_build_result(project=project))
> +
> +
> +class LexbuilderTests(
> + TestCase,
> ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
> + model = Lexbuilder
> +
> + def factoryMethod(self, project):
> + return factory.make_lexbuilder(
> + current_job=factory.make_build_result(project=project))
> +
> +
> +class BuildResultTests(
> + TestCase,
> ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
> + model = BuildResult
> + factoryMethod = factory.make_build_result
> +
> +
> +class BuildRequestTests(
> + TestCase,
> ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
> + model = BuildRequest
> + factoryMethod = factory.make_build_request
>
> Couldn't this be done as a parent class, something like
> BasePrivateModelTestCase or something?

I'm not sure I understand. What would we put in the parent class? Both
the model and the factory method are different on every class and they
already share a parent class which has the actual tests.

>
> #5
>
> +class ProjectTests(TestCase):
>
> There are no docstrings explaining any of the tests...

I'm not convinced using docstrings in test methods is a good idea as
nobody is going to be using these tests methods (i.e. they're not an
API), but when I have a test which is not trivial I write some comments
around the interesting lines. I do that for a couple tests here, but I
don't think the others need any comments as they'd just be the test's
name with s/_/ /

> #6
>
> +from .test_models import *
>
> Any idea why we actually need this? (I know we do, but is there a way
> we can drop it?) My concern is that missing tests from there won't
> necessarily affect localised test runs, but could be missed from just
> running test-web.

Django requires it.

> Also...see PEP8
>
> - Relative imports for intra-package imports are highly
> discouraged.
> Always use the absolute package path for all imports.
> Even now that PEP 328 [7] is fully implemented in Python 2.5,
> its style of explicit relative imports is actively discouraged;
> absolute imports are more portable and usually more readable.

Ok, changed.

>
> #7
>
> === added directory 'lib/offspring/web/queuemanager/sql'
> === added file
> 'lib/offspring/web/queuemanager/sql/project.postgresql_psycopg2.sql'
>
> What are these used for? I...

Read more...

Revision history for this message
Kevin McDermott (bigkevmcd) wrote :
Download full text (3.4 KiB)

> Where is the migration used?
>
> It's not; Offspring doesn't use south (Cody had concerns with it) so the
> migration scripts have to be applied manually.

Ok, I think we should definitely use South for migrations, it's in use successfully elsewhere in Canonical. Unless there are good reasons for not using it, then we can migrate that way over time.

At that point, we can fix this migration :-)

> >
> > Couldn't this be done as a parent class, something like
> > BasePrivateModelTestCase or something?
>
> I'm not sure I understand. What would we put in the parent class? Both
> the model and the factory method are different on every class and they
> already share a parent class which has the actual tests.

After having read more on Nose's test finding (which I don't particularly like), it seems that this would be doomed to failure because it finds all TestCase subclasses, so it wouldn't work :-(

>
> I'm not convinced using docstrings in test methods is a good idea as
> nobody is going to be using these tests methods (i.e. they're not an
> API), but when I have a test which is not trivial I write some comments
> around the interesting lines. I do that for a couple tests here, but I
> don't think the others need any comments as they'd just be the test's
> name with s/_/ /

Instead of comments, these should be docstrings...

But, the problem with tests without docstrings is that they don't convey anything of what they're actually doing.

def test_myfunction(self):
    self.assertEqual(7, my_function(1, 1))

Doesn't mean anything, but if the test passes, it's fine?

> Django requires it.

Why does Django require it? We removed it from Hexr recently and the tests still run :-)

> >
> > #7
> >
> > === added directory 'lib/offspring/web/queuemanager/sql'
> > === added file
> > 'lib/offspring/web/queuemanager/sql/project.postgresql_psycopg2.sql'
> >
> > What are these used for? Is there a speed benefit from using sqlite
> > in tests?
>
> Yes, it's incredibly faster to run using sqlite3, but there's also a
> test-web-postgres target to run the test-suite using postgres.
>
Sure, I see that, what a difference sqlite3 makes.

Ok, so is there some way we can deal with this in either South or in Django itself, so that it's not just in an SQL file?

This doesn't have to be fixed right away, but having bits of SQL lying around that may or may not be run isn't good...

> That's tricky because the Managers have to be declared on a models.Model
> instance and if I make MaybePrivateMixin a subclass of that, Project
> will end up inheriting from it twice: via MaybePrivateMixin and
> AccessGroupMixin, and that seems to case some weird issues. I'm sure I
> could create two separate mixins to workaround this but then I'm not
> sure it's worth it anymore.

Sure, I just hate to see duplication of code...maybe we can come up with something in a future refactoring...

> That was done in another branch, so I'd rather not do it here as it's
> not related to those changes I'm doing and would cause yet another
> conflict later on when I merge this back into the linaro branch.

Ok...seems reasonable...we do need to make sure we don't get caught by the Django/psyco...

Read more...

49. By Guilherme Salgado

Lots of docstrings for tests

Revision history for this message
Kevin McDermott (bigkevmcd) wrote :

Thanks for adding the docstrings...

pep8 throws a fit and lint chucks out at least one likely error :-)

But they're not in your branch, so let's not introduce any more noise :-)

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'Makefile'
2--- Makefile 2011-09-21 22:44:51 +0000
3+++ Makefile 2011-12-01 15:15:28 +0000
4@@ -46,7 +46,10 @@
5 ./.virtualenv/bin/nosetests offspring.master
6
7 test-web: web install-test-runner
8- ./bin/offspring-web test --settings=offspring.web.settings_test
9+ ./bin/offspring-web test queuemanager --settings=offspring.web.settings_test
10+
11+test-web-postgres: web install-test-runner
12+ ./bin/offspring-web test queuemanager --settings=offspring.web.settings_devel
13
14 test: master slave web install-test-runner test-web
15 ./.virtualenv/bin/nosetests -e 'queuemanager.*'
16
17=== modified file 'lib/offspring/web/queuemanager/models.py'
18--- lib/offspring/web/queuemanager/models.py 2011-09-27 18:42:21 +0000
19+++ lib/offspring/web/queuemanager/models.py 2011-12-01 15:15:28 +0000
20@@ -13,12 +13,16 @@
21 import math
22
23 from django.conf import settings
24-from django.contrib.auth.models import User
25+from django.contrib.auth.models import AnonymousUser, User
26 from django.db import (
27 connection,
28 models
29 )
30
31+from django_group_access.models import (
32+ AccessGroup,
33+ AccessGroupMixin,
34+ AccessManager)
35 from offspring.enums import ProjectBuildStates
36
37
38@@ -73,9 +77,9 @@
39 def get_absolute_url(self):
40 return "/project-groups/%s/" % self.name
41
42- @property
43- def projects(self):
44- return Project.objects.filter(project_group=self)
45+ def get_projects(self, user):
46+ return Project.all_objects.accessible_by_user(user).filter(
47+ project_group=self)
48
49 @property
50 def builder(self):
51@@ -83,11 +87,10 @@
52
53 @property
54 def is_active(self):
55- return bool(Project.objects.filter(project_group=self).exclude(is_active=False).count() != 0)
56-
57- @property
58- def needs_build(self):
59- return BuildRequest.objects.filter(project__project_group=self).exists()
60+ # Can safely use .all_objects here because we're not returning any
61+ # objects so there's no risk of leaking private stuff.
62+ projects = Project.all_objects.filter(project_group=self)
63+ return projects.exclude(is_active=False).count() > 0
64
65 @property
66 def average_build_time(self):
67@@ -111,7 +114,81 @@
68 return "UNKNOWN"
69
70
71-class Project(models.Model):
72+class PublicOnlyObjectsManager(models.Manager):
73+ """A model manager which only returns public objects."""
74+
75+ def get_query_set(self):
76+ return super(PublicOnlyObjectsManager, self).get_query_set().filter(
77+ models.Q(**{self.model._get_privacy_attr(): False}))
78+
79+
80+class AccessControlledObjectsManager(AccessManager):
81+ """A model manager which provides an extra API for querying objects a
82+ given user is allowed to see.
83+
84+ We extend AccessManager because we want the access-control filters to be
85+ applied only for private objects, but also because we allow AnonymousUsers
86+ to be passed to accessible_by_user().
87+ """
88+
89+ def _get_accessible_by_user_filter_rules(self, user):
90+ rules = super(AccessControlledObjectsManager,
91+ self)._get_accessible_by_user_filter_rules(user)
92+ return rules | models.Q(**{self.model._get_privacy_attr(): False})
93+
94+ def accessible_by_user(self, user):
95+ if isinstance(user, AnonymousUser):
96+ # If we're passed in an AnonymousUser we must not attempt to apply
97+ # the usual constraints because they rely on the given user
98+ # existing in the DB and that's not the case with AnonymousUsers.
99+ query = models.Q(**{self.model._get_privacy_attr(): False})
100+ return super(
101+ AccessControlledObjectsManager, self).get_query_set().filter(
102+ query)
103+ else:
104+ return super(
105+ AccessControlledObjectsManager, self).accessible_by_user(user)
106+
107+
108+class MaybePrivateMixin(object):
109+ """A model which may be private or linked to a private one.
110+
111+ If the model itself is private, it's expected to have an '_is_private'
112+ boolean attribute. Otherwise it must have an 'access_relation' attribute
113+ pointing to the linked model which has the '_is_private' attribute.
114+ """
115+ _privacy_attr_name = '_is_private'
116+ _is_visible_method_name = '_is_visible_to'
117+
118+ @classmethod
119+ def _get_privacy_attr(self):
120+ privacy_attr = self._privacy_attr_name
121+ if getattr(self, 'access_relation', None) is not None:
122+ privacy_attr = self.access_relation + '__' + privacy_attr
123+ return privacy_attr
124+
125+ def _get_access_controlled_object(self):
126+ if getattr(self, 'access_relation', None) is None:
127+ return self
128+ names = self.access_relation.split('__')
129+ obj = self
130+ while len(names):
131+ name = names.pop(0)
132+ obj = getattr(obj, name)
133+ return obj
134+
135+ @property
136+ def is_private(self):
137+ obj = self._get_access_controlled_object()
138+ return getattr(obj, self._privacy_attr_name)
139+
140+ def is_visible_to(self, user):
141+ is_visible_to = getattr(self._get_access_controlled_object(),
142+ self._is_visible_method_name)
143+ return is_visible_to(user)
144+
145+
146+class Project(AccessGroupMixin, MaybePrivateMixin):
147 #XXX: This should probably be managed in the database.
148 STATUS_CHOICES = (
149 (u'devel', u'Active Development'),
150@@ -121,7 +198,15 @@
151 (u'experimental', u'Experimental'),
152 (u'obsolete', u'Obsolete'),
153 )
154+ all_objects = AccessControlledObjectsManager()
155+ # Using PublicOnlyObjectsManager here we ensure that any oversights when
156+ # updating existing uses of Project.objects won't leak private projects.
157+ objects = PublicOnlyObjectsManager()
158
159+ _is_private = models.BooleanField(default=False, db_column='is_private')
160+ # We allow projects without an owner for backwards compatibility but
161+ # there's a DB constraint to ensure private projects always have an owner.
162+ owner = models.ForeignKey(User, blank=True, null=True)
163 name = models.SlugField('codename', max_length=200, primary_key=True, unique=True)
164 title = models.CharField('project title', max_length=30)
165 project_group = models.ForeignKey(ProjectGroup, blank=True, null=True)
166@@ -144,6 +229,16 @@
167 def __unicode__(self):
168 return self.display_name()
169
170+ def _is_visible_to(self, user):
171+ if not self._is_private or user == self.owner:
172+ return True
173+ if isinstance(user, AnonymousUser):
174+ # Private objects are never visible to anonymous users.
175+ return False
176+ access_groups = set(self.access_groups.all())
177+ user_groups = AccessGroup.objects.filter(members=user)
178+ return len(access_groups.intersection(user_groups)) > 0
179+
180 def get_absolute_url(self):
181 return "/projects/%s/" % self.name
182
183@@ -191,7 +286,7 @@
184 return u""
185
186
187-class Lexbuilder(models.Model):
188+class Lexbuilder(models.Model, MaybePrivateMixin):
189 name = models.SlugField(max_length=200, unique=True)
190 uri = models.CharField(max_length=200)
191 current_state = models.CharField(max_length=200, default="UNKNOWN", editable=False)
192@@ -204,6 +299,13 @@
193 current_job = models.ForeignKey("BuildResult", db_column="current_job_id", editable=False, blank=True, null=True)
194 notes = models.TextField('whiteboard', blank=True, null=True)
195
196+ access_relation = 'current_job__project'
197+ all_objects = AccessControlledObjectsManager()
198+ # Using PublicOnlyObjectsManager here we ensure that any oversights when
199+ # updating existing uses of Lexbuilder.objects won't leak anything related
200+ # to private projects.
201+ objects = PublicOnlyObjectsManager()
202+
203 class Meta:
204 db_table = "lexbuilders"
205
206@@ -265,7 +367,7 @@
207 return cursor.fetchone()[0]
208
209
210-class BuildResult(models.Model):
211+class BuildResult(models.Model, MaybePrivateMixin):
212 name = models.CharField('build name', max_length=200, blank=True, null=True, editable=False)
213 project = models.ForeignKey(Project, db_column="project_name", editable=False)
214 builder = models.ForeignKey(Lexbuilder, blank=True, null=True, editable=False)
215@@ -278,6 +380,13 @@
216 dispatched_at = models.DateTimeField('date dispatched', editable=False, null=True, blank=True)
217 notes = models.CharField(max_length=200, null=True, blank=True)
218
219+ access_relation = 'project'
220+ all_objects = AccessControlledObjectsManager()
221+ # Using PublicOnlyObjectsManager here we ensure that any oversights when
222+ # updating existing uses of BuildResult.objects won't leak anything
223+ # related to private projects.
224+ objects = PublicOnlyObjectsManager()
225+
226 class Meta:
227 db_table = "buildresults"
228 get_latest_by = "started_at"
229@@ -325,13 +434,20 @@
230 recentBuildFailures = staticmethod(recentBuildFailures)
231
232
233-class BuildRequest(models.Model):
234+class BuildRequest(models.Model, MaybePrivateMixin):
235 project = models.ForeignKey(Project, db_column="project_name")
236 requestor = models.ForeignKey(User, db_column="requestor_id", blank=True, null=True)
237 created_at = models.DateTimeField('date created', auto_now_add=True)
238 score = models.IntegerField(default=10)
239 reason = models.TextField('request reason', blank=True, max_length=200)
240
241+ access_relation = 'project'
242+ all_objects = AccessControlledObjectsManager()
243+ # Using PublicOnlyObjectsManager here we ensure that any oversights when
244+ # updating existing uses of BuildRequest.objects won't leak anything
245+ # related to private projects.
246+ objects = PublicOnlyObjectsManager()
247+
248 class Meta:
249 db_table = "buildrequests"
250
251@@ -450,7 +566,7 @@
252 return "%s subscribed to %s" % (self.user, self.project)
253
254
255-class Release(models.Model):
256+class Release(models.Model, MaybePrivateMixin):
257 STATUS_PENDING, STATUS_PUBLISHED, STATUS_REMOVED, STATUS_MISSING, STATUS_ERROR = range(5)
258 STATUS_CHOICES = (
259 (STATUS_PENDING, 'Pending Publication'),
260@@ -468,6 +584,13 @@
261 ("GM", "Gold Master"),
262 )
263
264+ access_relation = 'build__project'
265+ all_objects = AccessControlledObjectsManager()
266+ # Using PublicOnlyObjectsManager here we ensure that any oversights when
267+ # updating existing uses of Release.objects won't leak anything related to
268+ # private projects.
269+ objects = PublicOnlyObjectsManager()
270+
271 build = models.OneToOneField(BuildResult, primary_key=True, unique=True)
272 name = models.CharField('release name', max_length=200)
273 milestone = models.ForeignKey(LaunchpadProjectMilestone, null=True, blank=True)
274
275=== added directory 'lib/offspring/web/queuemanager/sql'
276=== added file 'lib/offspring/web/queuemanager/sql/project.postgresql_psycopg2.sql'
277--- lib/offspring/web/queuemanager/sql/project.postgresql_psycopg2.sql 1970-01-01 00:00:00 +0000
278+++ lib/offspring/web/queuemanager/sql/project.postgresql_psycopg2.sql 2011-12-01 15:15:28 +0000
279@@ -0,0 +1,5 @@
280+-- XXX: This doesn't apply when running tests because sqlite3 doesn't support
281+-- addition of new constraints to existing tables. Need to decide whether
282+-- it's preferred to just ignore this in our tests or use postgresql to run
283+-- the tests as well.
284+ALTER TABLE projects ADD CONSTRAINT "private_project_requires_owner" CHECK (is_private IS FALSE OR owner_id IS NOT NULL);
285
286=== modified file 'lib/offspring/web/queuemanager/tests/__init__.py'
287--- lib/offspring/web/queuemanager/tests/__init__.py 2011-11-30 14:01:37 +0000
288+++ lib/offspring/web/queuemanager/tests/__init__.py 2011-12-01 15:15:28 +0000
289@@ -0,0 +1,1 @@
290+from offspring.web.queuemanager.tests.test_models import *
291
292=== modified file 'lib/offspring/web/queuemanager/tests/factory.py'
293--- lib/offspring/web/queuemanager/tests/factory.py 2011-11-30 14:01:37 +0000
294+++ lib/offspring/web/queuemanager/tests/factory.py 2011-12-01 15:15:28 +0000
295@@ -1,6 +1,7 @@
296 from itertools import count
297
298 from django.contrib.auth.models import User
299+from django_group_access.models import AccessGroup
300
301 from offspring.web.queuemanager.models import (
302 BuildRequest,
303@@ -41,7 +42,8 @@
304 return User.objects.create_user(
305 userid, self.get_unique_email_address(), password)
306
307- def make_project(self, name=None, title=None):
308+ def make_project(self, name=None, title=None, is_private=False,
309+ project_group=None, owner=None, access_groups=[]):
310 """
311 Create a Project with the given name and title. If any of those is not
312 given we use an arbitrary value.
313@@ -50,7 +52,15 @@
314 name = self.get_unique_string()
315 if title is None:
316 title = self.get_unique_string()
317- return Project.objects.create(name=name, title=title)
318+ if owner is None:
319+ owner = self.make_user()
320+ project = Project.objects.create(
321+ name=name, title=title, _is_private=is_private, owner=owner,
322+ project_group=project_group)
323+ for group in access_groups:
324+ project.access_groups.add(group)
325+ project.save()
326+ return project
327
328 def make_project_group(self, name=None, title=None):
329 """
330@@ -108,5 +118,16 @@
331 project = self.make_project()
332 return BuildRequest.objects.create(project=project)
333
334+ def make_access_group(self, users):
335+ """
336+ Create an AccessGroup containing the given users.
337+ """
338+ name = self.get_unique_string()
339+ group = AccessGroup.objects.create(name=name)
340+ for user in users:
341+ group.members.add(user)
342+ group.save()
343+ return group
344+
345
346 factory = ObjectFactory()
347
348=== added file 'lib/offspring/web/queuemanager/tests/test_models.py'
349--- lib/offspring/web/queuemanager/tests/test_models.py 1970-01-01 00:00:00 +0000
350+++ lib/offspring/web/queuemanager/tests/test_models.py 2011-12-01 15:15:28 +0000
351@@ -0,0 +1,306 @@
352+from operator import attrgetter
353+
354+from django.contrib.auth.models import AnonymousUser
355+from django.test import TestCase
356+
357+from offspring.web.queuemanager.models import (
358+ BuildRequest,
359+ BuildResult,
360+ Lexbuilder,
361+ Project,
362+ Release)
363+from offspring.web.queuemanager.tests.factory import factory
364+
365+
366+class ProjectTests(TestCase):
367+
368+ def test_default_manager(self):
369+ """
370+ Make sure .all_objects is the default manager.
371+
372+ If that's not the case the admin UI will exclude all private objects
373+ and we would break model validation of unique fields if the user
374+ reuses a value from an object they're not allowed to see.
375+ """
376+ self.assertEquals(Project.all_objects, Project._default_manager)
377+
378+ def test_is_private_for_public_project(self):
379+ """
380+ Check that Project.is_private returns False for public projects.
381+ """
382+ project = factory.make_project(is_private=False)
383+ self.assertEqual(False, project.is_private)
384+
385+ def test_is_private_for_private_project(self):
386+ """
387+ Check that Project.is_private returns True for private projects.
388+ """
389+ private_project = factory.make_project(is_private=True)
390+ self.assertEqual(True, private_project.is_private)
391+
392+ def test_is_visible_to_anyone_if_public(self):
393+ """
394+ Check that public projects are visible to anyone.
395+ """
396+ project = factory.make_project(is_private=False)
397+ self.assertTrue(project.is_visible_to(AnonymousUser()))
398+
399+ def test_is_not_visible_to_anyone_if_private(self):
400+ """
401+ Check that private projects are not visible to anonymous users.
402+ """
403+ project = factory.make_project(is_private=True)
404+ self.assertFalse(project.is_visible_to(AnonymousUser()))
405+
406+ def test_is_visible_to_owner_if_private(self):
407+ """
408+ Check that private projects are visible to their owners.
409+ """
410+ project = factory.make_project(is_private=True)
411+ self.assertTrue(project.is_visible_to(project.owner))
412+
413+ def test_is_visible_to_member_of_access_group_if_private(self):
414+ """
415+ Check that private projects are visible to members of their access
416+ groups.
417+ """
418+ user = factory.make_user()
419+ group = factory.make_access_group([user])
420+ project = factory.make_project(is_private=True, access_groups=[group])
421+ self.assertTrue(project.is_visible_to(user))
422+
423+ def test_dot_objects_model_manager_only_returns_public_projects(self):
424+ """
425+ Check that the .objects model manager return only public projects.
426+ """
427+ project = factory.make_project(is_private=False)
428+ project2 = factory.make_project(is_private=True)
429+ self.assertEqual([project], list(Project.objects.all()))
430+
431+ def test_all_objects_model_manager(self):
432+ """
433+ Check that Project.all_objects includes private objects.
434+
435+ Project.all_objects() is a separate model manager which returns
436+ private objects as well. Its .all() method will return public and
437+ private objects regardless of whether or not the current user has
438+ access to them. It is to be used with caution.
439+ """
440+ project = factory.make_project(is_private=False)
441+ project2 = factory.make_project(is_private=True)
442+ self.assertEqual([project, project2], list(Project.all_objects.all()))
443+
444+ def test_accessible_by_user_with_anonymous_user(self):
445+ """
446+ Check that accessible_by_user() can be given an anonymous user as
447+ argument.
448+ """
449+ project = factory.make_project(is_private=True)
450+ project2 = factory.make_project(is_private=False)
451+ self.assertEqual(True, project.is_private)
452+ self.assertEqual(False, project2.is_private)
453+ objects = Project.all_objects.accessible_by_user(AnonymousUser())
454+ self.assertEqual([project2], list(objects))
455+
456+ def test_accessible_by_user_with_public_project(self):
457+ """
458+ Check that accessible_by_user() returns public projects regardless of
459+ the given user.
460+ """
461+ user = factory.make_user()
462+ project = factory.make_project(is_private=False)
463+ self.assertEqual(False, project.is_private)
464+ objects = Project.all_objects.accessible_by_user(user)
465+ self.assertEqual([project], list(objects))
466+
467+ def test_accessible_by_user_with_private_project(self):
468+ """
469+ Check that accessible_by_user() returns only the private projects the
470+ given user is allowed to see.
471+ """
472+ user = factory.make_user()
473+ group = factory.make_access_group([user])
474+ project = factory.make_project(is_private=True, access_groups=[group])
475+ # This second project is private but has no access groups set up, so
476+ # the user cannot see it.
477+ project2 = factory.make_project(is_private=True)
478+ self.assertEqual(True, project.is_private)
479+ self.assertEqual(True, project2.is_private)
480+ objects = Project.all_objects.accessible_by_user(user)
481+ self.assertEqual([project], list(objects))
482+
483+
484+class ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin(object):
485+ """A mixin with tests that work for the following models:
486+
487+ Release
488+ Lexbuilder
489+ BuildResult
490+ BuildRequest
491+
492+ You just need to mix this with TestCase in your subclass and define
493+ factoryMethod and model.
494+ """
495+ model = None
496+
497+ def factoryMethod(self, project):
498+ raise NotImplementedError()
499+
500+ def test_default_manager(self):
501+ """
502+ Make sure .all_objects is the default manager.
503+
504+ If that's not the case the admin UI will exclude all private objects
505+ and we would break model validation of unique fields if the user
506+ reuses a value from an object they're not allowed to see.
507+ """
508+ self.assertEquals(self.model.all_objects, self.model._default_manager)
509+
510+ def test_is_private_for_public_object(self):
511+ """
512+ Check that .is_private returns False for public objects.
513+ """
514+ public_object = self.factoryMethod(
515+ factory.make_project(is_private=False))
516+ self.assertFalse(public_object.is_private)
517+
518+ def test_is_private_for_private_object(self):
519+ """
520+ Check that .is_private returns True for private objects.
521+ """
522+ private_object = self.factoryMethod(
523+ factory.make_project(is_private=True))
524+ self.assertTrue(private_object.is_private)
525+
526+ def test_is_visible_to_anyone_if_public(self):
527+ """
528+ Check that public objects are visible to anyone.
529+ """
530+ public_object = self.factoryMethod(
531+ factory.make_project(is_private=False))
532+ self.assertTrue(public_object.is_visible_to(AnonymousUser()))
533+
534+ def test_is_not_visible_to_anyone_if_private(self):
535+ """
536+ Check that private objects are not visible to anonymous users.
537+ """
538+ private_object = self.factoryMethod(
539+ factory.make_project(is_private=True))
540+ self.assertFalse(private_object.is_visible_to(AnonymousUser()))
541+
542+ def test_is_visible_to_owner_if_private(self):
543+ """
544+ Check that private objects are visible to their project's owners.
545+ """
546+ # The object we're dealing with inherits the access-control rules from
547+ # the project it's related to, so the owner that matters is the
548+ # project owner.
549+ owner = factory.make_user()
550+ private_object = self.factoryMethod(
551+ factory.make_project(is_private=True, owner=owner))
552+ self.assertTrue(private_object.is_visible_to(owner))
553+
554+ def test_is_visible_to_member_of_access_group_if_private(self):
555+ """
556+ Check that private objects are visible to members of their project's
557+ access groups.
558+ """
559+ user = factory.make_user()
560+ group = factory.make_access_group([user])
561+ private_object = self.factoryMethod(
562+ factory.make_project(is_private=True, access_groups=[group]))
563+ self.assertTrue(private_object.is_visible_to(user))
564+
565+ def test_dot_objects_model_manager_only_returns_public_objects(self):
566+ """
567+ Check that the .objects model manager return only public objects.
568+ """
569+ private_object = self.factoryMethod(
570+ factory.make_project(is_private=True))
571+ public_object = self.factoryMethod(
572+ factory.make_project(is_private=False))
573+ self.assertEqual([public_object], list(self.model.objects.all()))
574+
575+ def test_all_objects_model_manager(self):
576+ """
577+ Check that .all_objects includes private objects.
578+
579+ self.model.all_objects() is a separate model manager which returns
580+ private objects as well. Its .all() method will return public and
581+ private objects regardless of whether or not the current user has
582+ access to them. It is to be used with caution.
583+ """
584+ public_object = self.factoryMethod(
585+ project=factory.make_project(is_private=False))
586+ private_object = self.factoryMethod(
587+ project=factory.make_project(is_private=True))
588+ self.assertEqual([public_object, private_object],
589+ list(self.model.all_objects.all()))
590+
591+ def test_accessible_by_user(self):
592+ """
593+ Check that accessible_by_user() returns only the private objects the
594+ user is allowed to see.
595+ """
596+ user = factory.make_user()
597+ group = factory.make_access_group([user])
598+ visible_object = self.factoryMethod(
599+ factory.make_project(is_private=True, access_groups=[group]))
600+ # This object is linked to a private project which has no access
601+ # groups set up, so the user cannot see it.
602+ invisible_object = self.factoryMethod(
603+ project=factory.make_project(is_private=True))
604+ self.assertEqual(
605+ [visible_object],
606+ list(self.model.all_objects.accessible_by_user(user)))
607+
608+
609+class ReleaseTests(
610+ TestCase, ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
611+ model = Release
612+
613+ def factoryMethod(self, project):
614+ return factory.make_release(
615+ build=factory.make_build_result(project=project))
616+
617+
618+class LexbuilderTests(
619+ TestCase, ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
620+ model = Lexbuilder
621+
622+ def factoryMethod(self, project):
623+ return factory.make_lexbuilder(
624+ current_job=factory.make_build_result(project=project))
625+
626+
627+class BuildResultTests(
628+ TestCase, ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
629+ model = BuildResult
630+ factoryMethod = factory.make_build_result
631+
632+
633+class BuildRequestTests(
634+ TestCase, ReleaseOrLexbuilderOrBuildResultOrBuildRequestTestsMixin):
635+ model = BuildRequest
636+ factoryMethod = factory.make_build_request
637+
638+
639+class ProjectGroupTests(TestCase):
640+
641+ def test_get_projects_filters_private_objects(self):
642+ """
643+ ProjectGroup.get_projects() will return only the private projects the
644+ given user is allowed to see.
645+ """
646+ user = factory.make_user()
647+ group = factory.make_project_group()
648+ public_project = factory.make_project(
649+ is_private=False, project_group=group)
650+ private_project_visible_to_user = factory.make_project(
651+ is_private=True, project_group=group, owner=user)
652+ private_project = factory.make_project(
653+ is_private=True, project_group=group)
654+ self.assertEqual(
655+ sorted([public_project, private_project_visible_to_user],
656+ key=attrgetter('name')),
657+ sorted(group.get_projects(user), key=attrgetter('name')))
658
659=== modified file 'lib/offspring/web/queuemanager/views.py'
660--- lib/offspring/web/queuemanager/views.py 2011-08-13 06:28:28 +0000
661+++ lib/offspring/web/queuemanager/views.py 2011-12-01 15:15:28 +0000
662@@ -241,6 +241,7 @@
663
664 pageData = {
665 'projectGroup' : pg,
666+ 'projects': pg.get_projects(request.user),
667 'pillar' : 'projects',
668 'build_stats' : build_stats,
669 'projectgroup_build_results' : projectgroup_build_results,
670
671=== modified file 'lib/offspring/web/settings.py'
672--- lib/offspring/web/settings.py 2011-08-11 00:16:09 +0000
673+++ lib/offspring/web/settings.py 2011-12-01 15:15:28 +0000
674@@ -96,6 +96,7 @@
675 'djcelery',
676 'djkombu',
677 'piston',
678+ 'django_group_access',
679 # Offspring Apps
680 'offspring.web.queuemanager',
681 )
682
683=== modified file 'lib/offspring/web/templates/queuemanager/projectgroup_details.html'
684--- lib/offspring/web/templates/queuemanager/projectgroup_details.html 2010-11-29 08:27:24 +0000
685+++ lib/offspring/web/templates/queuemanager/projectgroup_details.html 2011-12-01 15:15:28 +0000
686@@ -32,7 +32,7 @@
687 <h3>Project</h3></td><td></td><td><h3>Last Build</h3></td><td width="30%"><h3>Date</h3></td><td width="15%"><h3>Result</h3>
688 </td>
689 </tr>
690- {% for project in projectGroup.projects %}
691+ {% for project in projects %}
692 <tr>
693 <td>
694 {% if project.needs_build %}<img src="/assets/images/33.png" title="Pending Build">{% else %}{% ifequal project.latest_build.result "FAILED" %}<img src="/assets/images/34.png" title="Build Failure">{% else %} {% ifequal project.latest_build.result "COMPLETED" %} <img src="/assets/images/35.png" title="Build Successful"> {% else %} <img src="/assets/images/36.png" title="Not yet built">{% endifequal %}{% endifequal %}{% endif %}</td><td> <a href="{% url offspring.web.queuemanager.views.project_details project.name %}">{{ project.title }}</a>
695
696=== added directory 'migration'
697=== added file 'migration/001-project-privacy.sql'
698--- migration/001-project-privacy.sql 1970-01-01 00:00:00 +0000
699+++ migration/001-project-privacy.sql 2011-12-01 15:15:28 +0000
700@@ -0,0 +1,10 @@
701+ALTER TABLE projects ADD COLUMN "is_private" boolean NOT NULL default FALSE;
702+ALTER TABLE projects ADD COLUMN "owner_id" integer REFERENCES "auth_user" ("id") DEFERRABLE INITIALLY DEFERRED;
703+ALTER TABLE projects ADD CONSTRAINT "private_project_requires_owner" CHECK (is_private IS FALSE OR owner_id IS NOT NULL);
704+CREATE TABLE "projects_access_groups" (
705+ "id" serial NOT NULL PRIMARY KEY,
706+ "project_id" varchar(200) NOT NULL,
707+ "accessgroup_id" integer NOT NULL REFERENCES "django_group_access_accessgroup" ("id") DEFERRABLE INITIALLY DEFERRED,
708+ UNIQUE ("project_id", "accessgroup_id")
709+);
710+ALTER TABLE "projects_access_groups" ADD CONSTRAINT "project_id_refs_name_44bd562d" FOREIGN KEY ("project_id") REFERENCES "projects" ("name") DEFERRABLE INITIALLY DEFERRED;
711
712=== modified file 'requirements/requirements.web.txt'
713--- requirements/requirements.web.txt 2011-08-11 00:16:09 +0000
714+++ requirements/requirements.web.txt 2011-12-01 15:15:28 +0000
715@@ -11,3 +11,4 @@
716 celery>=2.3.1
717 django-celery>=2.3.0
718 django-kombu>=0.9.4
719+django-group-access>=0.0.1

Subscribers

People subscribed via source and target branches