launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #18447
[Merge] lp:~cjwatson/launchpad/git-target-inline-default-repo into lp:launchpad
Colin Watson has proposed merging lp:~cjwatson/launchpad/git-target-inline-default-repo into lp:launchpad.
Commit message:
If a project has a default Git repository, show its branches on the project code page.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/git-target-inline-default-repo/+merge/258033
If a project has a default Git repository, show its branches on the project code page.
This is a rushed job. We need to do something much smarter, perhaps having a mode switch to let a project say which it prefers, and the product-branch-summary handling is at best rough. But it makes the repository at least be visible somehow, while it was previously hard to find.
I haven't done anything for packages yet; that will come a little later.
--
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~cjwatson/launchpad/git-target-inline-default-repo into lp:launchpad.
=== modified file 'lib/lp/code/browser/branchlisting.py'
--- lib/lp/code/browser/branchlisting.py 2015-04-22 12:03:05 +0000
+++ lib/lp/code/browser/branchlisting.py 2015-05-01 13:25:48 +0000
@@ -69,6 +69,7 @@
from lp.bugs.interfaces.bugbranch import IBugBranchSet
from lp.code.browser.branch import BranchMirrorMixin
from lp.code.browser.branchmergeproposallisting import ActiveReviewsView
+from lp.code.browser.gitrepository import GitRefBatchNavigator
from lp.code.browser.summary import BranchCountSummaryView
from lp.code.enums import (
BranchLifecycleStatus,
@@ -85,6 +86,7 @@
from lp.code.interfaces.branchcollection import IAllBranches
from lp.code.interfaces.branchnamespace import IBranchNamespacePolicy
from lp.code.interfaces.branchtarget import IBranchTarget
+from lp.code.interfaces.gitrepository import IGitRepositorySet
from lp.code.interfaces.revision import IRevisionSet
from lp.code.interfaces.revisioncache import IRevisionCache
from lp.code.interfaces.seriessourcepackagebranch import (
@@ -525,6 +527,7 @@
field_names = ['lifecycle', 'sort_by']
development_focus_branch = None
show_set_development_focus = False
+ default_git_repository = None
custom_widget('lifecycle', LaunchpadDropdownWidget)
custom_widget('sort_by', LaunchpadDropdownWidget)
# Showing the series links is only really useful on product listing
@@ -1096,6 +1099,26 @@
else:
return None
+ @cachedproperty
+ def default_git_repository(self):
+ repository = getUtility(IGitRepositorySet).getDefaultRepository(
+ self.context)
+ if repository is None:
+ return None
+ elif check_permission('launchpad.View', repository):
+ return repository
+ else:
+ return None
+
+ def default_git_repository_branches(self):
+ """All branches in the default Git repository, sorted for display."""
+ return GitRefBatchNavigator(self, self.default_git_repository)
+
+ @property
+ def has_default_git_repository(self):
+ """Is there a default Git repository?"""
+ return self.default_git_repository is not None
+
@property
def no_branch_message(self):
if (self.selected_lifecycle_status is not None
=== modified file 'lib/lp/code/browser/gitrepository.py'
--- lib/lp/code/browser/gitrepository.py 2015-04-21 09:31:58 +0000
+++ lib/lp/code/browser/gitrepository.py 2015-05-01 13:25:48 +0000
@@ -6,6 +6,7 @@
__metaclass__ = type
__all__ = [
+ 'GitRefBatchNavigator',
'GitRepositoryBreadcrumb',
'GitRepositoryContextMenu',
'GitRepositoryNavigation',
@@ -13,7 +14,7 @@
'GitRepositoryView',
]
-from bzrlib import urlutils
+from storm.expr import Desc
from zope.interface import implements
from lp.app.browser.informationtype import InformationTypePortletMixin
@@ -115,13 +116,19 @@
implements(IGitRefBatchNavigator)
def __init__(self, view, context):
+ self.context = context
super(GitRefBatchNavigator, self).__init__(
- context.branches, view.request,
+ self._branches, view.request,
size=config.launchpad.branchlisting_batch_size)
self.view = view
self.column_count = 3
@property
+ def _branches(self):
+ from lp.code.model.gitref import GitRef
+ return self.context.branches.order_by(Desc(GitRef.committer_date))
+
+ @property
def table_class(self):
# XXX: MichaelHudson 2007-10-18 bug=153894: This means there are two
# ways of sorting a one-page branch listing, which is confusing and
@@ -151,22 +158,6 @@
self.request, "launchpad.LimitedView", authorised_people)
@property
- def anon_url(self):
- if self.context.visibleByUser(None):
- return urlutils.join(
- config.codehosting.git_anon_root, self.context.shortened_path)
- else:
- return None
-
- @property
- def ssh_url(self):
- if self.user is not None:
- return urlutils.join(
- config.codehosting.git_ssh_root, self.context.shortened_path)
- else:
- return None
-
- @property
def user_can_push(self):
"""Whether the user can push to this branch."""
return check_permission("launchpad.Edit", self.context)
=== modified file 'lib/lp/code/browser/tests/test_gitrepository.py'
--- lib/lp/code/browser/tests/test_gitrepository.py 2015-03-24 15:15:23 +0000
+++ lib/lp/code/browser/tests/test_gitrepository.py 2015-05-01 13:25:48 +0000
@@ -8,7 +8,6 @@
from datetime import datetime
from BeautifulSoup import BeautifulSoup
-from bzrlib import urlutils
from fixtures import FakeLogger
import pytz
from testtools.matchers import Equals
@@ -21,7 +20,6 @@
from lp.code.interfaces.gitrepository import GIT_FEATURE_FLAG
from lp.code.interfaces.revision import IRevisionSet
from lp.registry.interfaces.person import PersonVisibility
-from lp.services.config import config
from lp.services.features.testing import FeatureFixture
from lp.services.webapp.publisher import canonical_url
from lp.testing import (
@@ -70,49 +68,6 @@
super(TestGitRepositoryView, self).setUp()
self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
- def test_anon_url_for_public(self):
- # Public repositories have an anonymous URL, visible to anyone.
- repository = self.factory.makeGitRepository()
- view = create_initialized_view(repository, "+index")
- expected_url = urlutils.join(
- config.codehosting.git_anon_root, repository.shortened_path)
- self.assertEqual(expected_url, view.anon_url)
-
- def test_anon_url_not_for_private(self):
- # Private repositories do not have an anonymous URL.
- owner = self.factory.makePerson()
- repository = self.factory.makeGitRepository(
- owner=owner, information_type=InformationType.USERDATA)
- with person_logged_in(owner):
- view = create_initialized_view(repository, "+index")
- self.assertIsNone(view.anon_url)
-
- def test_ssh_url_for_public_logged_in(self):
- # Public repositories have an SSH URL, visible if logged in.
- repository = self.factory.makeGitRepository()
- with person_logged_in(repository.owner):
- view = create_initialized_view(repository, "+index")
- expected_url = urlutils.join(
- config.codehosting.git_ssh_root, repository.shortened_path)
- self.assertEqual(expected_url, view.ssh_url)
-
- def test_ssh_url_for_public_not_anonymous(self):
- # Public repositories do not have an SSH URL if not logged in.
- repository = self.factory.makeGitRepository()
- view = create_initialized_view(repository, "+index")
- self.assertIsNone(view.ssh_url)
-
- def test_ssh_url_for_private(self):
- # Private repositories have an SSH URL.
- owner = self.factory.makePerson()
- repository = self.factory.makeGitRepository(
- owner=owner, information_type=InformationType.USERDATA)
- with person_logged_in(owner):
- view = create_initialized_view(repository, "+index")
- expected_url = urlutils.join(
- config.codehosting.git_ssh_root, repository.shortened_path)
- self.assertEqual(expected_url, view.ssh_url)
-
def test_user_can_push(self):
# A user can push if they have edit permissions.
repository = self.factory.makeGitRepository()
=== modified file 'lib/lp/code/browser/tests/test_product.py'
--- lib/lp/code/browser/tests/test_product.py 2014-02-25 06:42:01 +0000
+++ lib/lp/code/browser/tests/test_product.py 2015-05-01 13:25:48 +0000
@@ -19,8 +19,13 @@
ServiceUsage,
)
from lp.code.enums import BranchType
+from lp.code.interfaces.gitrepository import (
+ GIT_FEATURE_FLAG,
+ IGitRepositorySet,
+ )
from lp.code.interfaces.revision import IRevisionSet
from lp.registry.enums import BranchSharingPolicy
+from lp.services.features.testing import FeatureFixture
from lp.services.webapp import canonical_url
from lp.testing import (
ANONYMOUS,
@@ -207,6 +212,39 @@
expected = 'There are no branches for %s' % product.displayname
self.assertIn(expected, html)
+ def test_no_default_git_repository(self):
+ # If there is no default Git repository, Product:+branches does not
+ # try to render one.
+ product = self.factory.makeProduct()
+ view = create_initialized_view(
+ product, '+branches', rootsite='code', principal=product.owner)
+ self.assertIsNone(view.default_git_repository)
+ self.assertFalse(view.has_default_git_repository)
+ content = view()
+ self.assertNotIn('git clone', content)
+
+ def test_default_git_repository(self):
+ # If there is a default Git repository, Product:+branches shows a
+ # summary of its branches.
+ self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
+ product = self.factory.makeProduct()
+ repository = self.factory.makeGitRepository(target=product)
+ self.factory.makeGitRefs(
+ repository=repository,
+ paths=[u"refs/heads/master", u"refs/heads/another-branch"])
+ with person_logged_in(product.owner):
+ getUtility(IGitRepositorySet).setDefaultRepository(
+ product, repository)
+ view = create_initialized_view(
+ product, '+branches', rootsite='code', principal=product.owner)
+ self.assertEqual(repository, view.default_git_repository)
+ self.assertTrue(view.has_default_git_repository)
+ content = view()
+ self.assertIn('git clone', content)
+ # XXX cjwatson 2015-04-30: These tests are not very precise.
+ self.assertIn('master', content)
+ self.assertIn('another-branch', content)
+
class TestProductCodeIndexServiceUsages(ProductTestBase, BrowserTestCase):
"""Tests for the product code page, especially the usage messasges."""
=== modified file 'lib/lp/code/interfaces/gitrepository.py'
--- lib/lp/code/interfaces/gitrepository.py 2015-04-28 16:39:15 +0000
+++ lib/lp/code/interfaces/gitrepository.py 2015-05-01 13:25:48 +0000
@@ -217,6 +217,12 @@
"The identity of this repository: a VCS-independent synonym for "
"git_identity.")
+ anon_url = Attribute(
+ "An anonymous (git://) URL for this repository, or None in the case "
+ "of private repositories.")
+
+ ssh_url = Attribute("A git+ssh:// URL for this repository.")
+
refs = exported(CollectionField(
title=_("The references present in this repository."),
readonly=True,
=== modified file 'lib/lp/code/model/gitrepository.py'
--- lib/lp/code/model/gitrepository.py 2015-04-28 16:39:15 +0000
+++ lib/lp/code/model/gitrepository.py 2015-05-01 13:25:48 +0000
@@ -322,6 +322,21 @@
config.codehosting.git_browse_root, self.unique_name)
@property
+ def anon_url(self):
+ """See `IGitRepository`."""
+ if self.visibleByUser(None):
+ return urlutils.join(
+ config.codehosting.git_anon_root, self.shortened_path)
+ else:
+ return None
+
+ @property
+ def ssh_url(self):
+ """See `IGitRepository`."""
+ return urlutils.join(
+ config.codehosting.git_ssh_root, self.shortened_path)
+
+ @property
def private(self):
return self.information_type in PRIVATE_INFORMATION_TYPES
=== modified file 'lib/lp/code/model/tests/test_gitrepository.py'
--- lib/lp/code/model/tests/test_gitrepository.py 2015-04-28 16:39:15 +0000
+++ lib/lp/code/model/tests/test_gitrepository.py 2015-05-01 13:25:48 +0000
@@ -11,6 +11,7 @@
import hashlib
import json
+from bzrlib import urlutils
from lazr.lifecycle.event import ObjectModifiedEvent
from lazr.lifecycle.snapshot import Snapshot
import transaction
@@ -72,6 +73,7 @@
)
from lp.registry.interfaces.personproduct import IPersonProductFactory
from lp.registry.tests.test_accesspolicy import get_policies_for_artifact
+from lp.services.config import config
from lp.services.database.constants import UTC_NOW
from lp.services.features.testing import FeatureFixture
from lp.services.mail import stub
@@ -353,21 +355,53 @@
# actually notices any interesting kind of repository modifications.
-class TestCodebrowse(TestCaseWithFactory):
- """Tests for Git repository codebrowse support."""
+class TestGitRepositoryURLs(TestCaseWithFactory):
+ """Tests for Git repository URLs."""
layer = DatabaseFunctionalLayer
def setUp(self):
- super(TestCodebrowse, self).setUp()
+ super(TestGitRepositoryURLs, self).setUp()
self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
- def test_simple(self):
+ def test_codebrowse_url(self):
# The basic codebrowse URL for a repository is an 'https' URL.
repository = self.factory.makeGitRepository()
- self.assertEqual(
- "https://git.launchpad.dev/" + repository.unique_name,
- repository.getCodebrowseUrl())
+ expected_url = urlutils.join(
+ config.codehosting.git_browse_root, repository.unique_name)
+ self.assertEqual(expected_url, repository.getCodebrowseUrl())
+
+ def test_anon_url_for_public(self):
+ # Public repositories have an anonymous URL, visible to anyone.
+ repository = self.factory.makeGitRepository()
+ expected_url = urlutils.join(
+ config.codehosting.git_anon_root, repository.shortened_path)
+ self.assertEqual(expected_url, repository.anon_url)
+
+ def test_anon_url_not_for_private(self):
+ # Private repositories do not have an anonymous URL.
+ owner = self.factory.makePerson()
+ repository = self.factory.makeGitRepository(
+ owner=owner, information_type=InformationType.USERDATA)
+ with person_logged_in(owner):
+ self.assertIsNone(repository.anon_url)
+
+ def test_ssh_url_for_public(self):
+ # Public repositories have an SSH URL.
+ repository = self.factory.makeGitRepository()
+ expected_url = urlutils.join(
+ config.codehosting.git_ssh_root, repository.shortened_path)
+ self.assertEqual(expected_url, repository.ssh_url)
+
+ def test_ssh_url_for_private(self):
+ # Private repositories have an SSH URL.
+ owner = self.factory.makePerson()
+ repository = self.factory.makeGitRepository(
+ owner=owner, information_type=InformationType.USERDATA)
+ with person_logged_in(owner):
+ expected_url = urlutils.join(
+ config.codehosting.git_ssh_root, repository.shortened_path)
+ self.assertEqual(expected_url, repository.ssh_url)
class TestGitRepositoryNamespace(TestCaseWithFactory):
=== modified file 'lib/lp/code/templates/gitref-listing.pt'
--- lib/lp/code/templates/gitref-listing.pt 2015-04-29 15:06:39 +0000
+++ lib/lp/code/templates/gitref-listing.pt 2015-05-01 13:25:48 +0000
@@ -24,7 +24,7 @@
</div>
</tal:needs-batch>
- <table tal:attributes="class context/table_class" id="branchtable">
+ <table tal:attributes="class context/table_class" id="gitreftable">
<thead>
<tr>
<th>Name</th>
=== modified file 'lib/lp/code/templates/gitrepository-management.pt'
--- lib/lp/code/templates/gitrepository-management.pt 2015-03-04 16:49:42 +0000
+++ lib/lp/code/templates/gitrepository-management.pt 2015-05-01 13:25:48 +0000
@@ -7,15 +7,15 @@
<dl id="clone-url">
<dt>Get this repository:</dt>
<dd>
- <tal:anonymous condition="view/anon_url">
+ <tal:anonymous condition="context/anon_url">
<tt class="command">
- git clone <span class="anon-url" tal:content="view/anon_url" />
+ git clone <span class="anon-url" tal:content="context/anon_url" />
</tt>
<br />
</tal:anonymous>
- <tal:ssh condition="view/ssh_url">
+ <tal:ssh condition="view/user">
<tt class="command">
- git clone <span class="ssh-url" tal:content="view/ssh_url" />
+ git clone <span class="ssh-url" tal:content="context/ssh_url" />
</tt>
</tal:ssh>
</dd>
=== modified file 'lib/lp/code/templates/product-branch-summary.pt'
--- lib/lp/code/templates/product-branch-summary.pt 2012-10-08 02:02:19 +0000
+++ lib/lp/code/templates/product-branch-summary.pt 2015-05-01 13:25:48 +0000
@@ -56,7 +56,8 @@
</p>
</div>
- <tal:no-branches condition="not: view/branch_count">
+ <tal:no-branches
+ condition="python: not view.branch_count and not view.has_default_git_repository">
There are no branches for <tal:project-name replace="context/displayname"/>
in Launchpad.
<tal:can-configure condition="view/can_configure_branches">
@@ -78,6 +79,23 @@
</tal:can-configure>
</tal:no-branches>
+ <div tal:condition="view/has_default_git_repository"
+ style="margin: 1em 0"
+ tal:define="repository view/default_git_repository">
+ You can
+ <a tal:attributes="href repository/getCodebrowseUrl">browse the
+ source code</a>
+ for the default Git repository or get a copy of the repository using
+ the command:<br/>
+ <tt class="command">git clone
+ <tal:logged-in condition="view/user">
+ <tal:git-ssh-url replace="repository/ssh_url"/>
+ </tal:logged-in>
+ <tal:not-logged-in condition="not: view/user">
+ <tal:git-anon-url replace="repository/anon_url"/>
+ </tal:not-logged-in>
+ </div>
+
<tal:has-branches condition="view/branch_count">
<div tal:condition="view/has_development_focus_branch"
style="margin: 1em 0"
@@ -99,7 +117,7 @@
<tal:has-user condition="view/user">
<p id="push-instructions"
tal:condition="not: context/codehosting_usage/enumvalue:UNKNOWN">
- You can push the branch directly to Launchpad with the command:<br />
+ You can push a Bazaar branch directly to Launchpad with the command:<br />
<tt class="command">
bzr push lp:~<tal:user replace="view/user/name"/>/<tal:project replace="context/name"/>/<tal:series replace="context/name"/>
</tt>
=== modified file 'lib/lp/code/templates/product-branches.pt'
--- lib/lp/code/templates/product-branches.pt 2012-10-08 02:02:19 +0000
+++ lib/lp/code/templates/product-branches.pt 2015-05-01 13:25:48 +0000
@@ -60,9 +60,22 @@
condition="not: view/context/codehosting_usage/enumvalue:UNKNOWN"
replace="structure context/@@+portlet-product-codestatistics" />
+ <tal:has-default-git-repository condition="view/has_default_git_repository">
+ <div id="default-repository-branches" class="portlet"
+ tal:define="repository view/default_git_repository;
+ branches view/default_git_repository_branches">
+ <h2>Git branches</h2>
+ <tal:default-repository-branches
+ replace="structure branches/@@+ref-listing" />
+ </div>
+ </tal:has-default-git-repository>
+
<tal:has-branches condition="view/branch_count"
define="branches view/branches">
- <tal:branchlisting content="structure branches/@@+branch-listing" />
+ <div class="portlet">
+ <h2>Bazaar branches</h2>
+ <tal:branchlisting content="structure branches/@@+branch-listing" />
+ </div>
</tal:has-branches>
</tal:main>
=== modified file 'lib/lp/registry/doc/product.txt'
--- lib/lp/registry/doc/product.txt 2015-01-29 18:43:52 +0000
+++ lib/lp/registry/doc/product.txt 2015-05-01 13:25:48 +0000
@@ -521,6 +521,31 @@
landscape
+Products with Git repositories
+------------------------------
+
+Products are considered to officially support Launchpad as a location for
+their code if they have a default Git repository.
+
+ >>> from lp.code.interfaces.gitrepository import (
+ ... GIT_FEATURE_FLAG,
+ ... IGitRepositorySet,
+ ... )
+ >>> from lp.services.features.testing import FeatureFixture
+ >>> firefox.development_focus.branch = None
+ >>> print firefox.official_codehosting
+ False
+ >>> print firefox.codehosting_usage.name
+ UNKNOWN
+ >>> with FeatureFixture({GIT_FEATURE_FLAG: 'on'}):
+ ... getUtility(IGitRepositorySet).setDefaultRepository(
+ ... firefox, factory.makeGitRepository(target=firefox))
+ >>> print firefox.official_codehosting
+ True
+ >>> print firefox.codehosting_usage.name
+ LAUNCHPAD
+
+
Primary translatable
--------------------
=== modified file 'lib/lp/registry/model/product.py'
--- lib/lp/registry/model/product.py 2015-03-17 10:45:07 +0000
+++ lib/lp/registry/model/product.py 2015-05-01 13:25:48 +0000
@@ -116,6 +116,7 @@
)
from lp.code.enums import BranchType
from lp.code.interfaces.branch import DEFAULT_BRANCH_STATUS_IN_LISTING
+from lp.code.interfaces.gitrepository import IGitRepositorySet
from lp.code.model.branch import Branch
from lp.code.model.branchnamespace import BRANCH_POLICY_ALLOWED_TYPES
from lp.code.model.hasbranches import (
@@ -581,7 +582,10 @@
@property
def official_codehosting(self):
- return self.development_focus.branch is not None
+ repository = getUtility(IGitRepositorySet).getDefaultRepository(self)
+ return (
+ self.development_focus.branch is not None or
+ repository is not None)
@property
def official_anything(self):
@@ -613,9 +617,11 @@
@property
def codehosting_usage(self):
- if self.development_focus.branch is None:
+ repository = getUtility(IGitRepositorySet).getDefaultRepository(self)
+ if self.development_focus.branch is None and repository is None:
return ServiceUsage.UNKNOWN
- elif self.development_focus.branch.branch_type == BranchType.HOSTED:
+ elif (repository is not None or
+ self.development_focus.branch.branch_type == BranchType.HOSTED):
return ServiceUsage.LAUNCHPAD
elif self.development_focus.branch.branch_type in (
BranchType.MIRRORED,
=== modified file 'lib/lp/registry/templates/productseries-setbranch.pt'
--- lib/lp/registry/templates/productseries-setbranch.pt 2012-10-09 01:07:52 +0000
+++ lib/lp/registry/templates/productseries-setbranch.pt 2015-05-01 13:25:48 +0000
@@ -19,7 +19,7 @@
<div metal:fill-slot="main">
<p id="push-instructions">
- You can push the branch directly to Launchpad with the command:<br />
+ You can push a Bazaar branch directly to Launchpad with the command:<br />
<tt class="command">
bzr push lp:~<tal:user replace="view/user/name"/>/<tal:project replace="context/product/name"/>/<tal:series replace="context/name"/>
</tt>
=== modified file 'lib/lp/registry/tests/test_service_usage.py'
--- lib/lp/registry/tests/test_service_usage.py 2012-01-01 02:58:52 +0000
+++ lib/lp/registry/tests/test_service_usage.py 2015-05-01 13:25:48 +0000
@@ -3,8 +3,15 @@
__metaclass__ = type
+from zope.component import getUtility
+
from lp.app.enums import ServiceUsage
from lp.code.enums import BranchType
+from lp.code.interfaces.gitrepository import (
+ GIT_FEATURE_FLAG,
+ IGitRepositorySet,
+ )
+from lp.services.features.testing import FeatureFixture
from lp.testing import (
login_person,
TestCaseWithFactory,
@@ -192,7 +199,7 @@
self.target.codehosting_usage)
def test_codehosting_hosted_branch(self):
- # A branch on Launchpad is HOSTED.
+ # A branch on Launchpad has LAUNCHPAD usage.
login_person(self.target.owner)
self.target.development_focus.branch = self.factory.makeProductBranch(
product=self.target,
@@ -201,6 +208,17 @@
ServiceUsage.LAUNCHPAD,
self.target.codehosting_usage)
+ def test_codehosting_default_git_repository(self):
+ # A default Git repository on Launchpad has LAUNCHPAD usage.
+ self.useFixture(FeatureFixture({GIT_FEATURE_FLAG: u"on"}))
+ login_person(self.target.owner)
+ repository = self.factory.makeGitRepository(target=self.target)
+ getUtility(IGitRepositorySet).setDefaultRepository(
+ self.target, repository)
+ self.assertEqual(
+ ServiceUsage.LAUNCHPAD,
+ self.target.codehosting_usage)
+
class TestProductSeriesUsageEnums(
TestCaseWithFactory,
Follow ups