launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #28112
[Merge] ~cjwatson/launchpad:distribution-information-type into launchpad:master
Colin Watson has proposed merging ~cjwatson/launchpad:distribution-information-type into launchpad:master.
Commit message:
Add Distribution.information_type
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/415522
This is mostly copied from `Product`, especially the validation logic and the test cases. There are no sharing policies yet, and no UI for setting the information type; these will come in future branches.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:distribution-information-type into launchpad:master.
diff --git a/lib/lp/archivepublisher/tests/test_dominator.py b/lib/lp/archivepublisher/tests/test_dominator.py
index aefd466..01c56e4 100755
--- a/lib/lp/archivepublisher/tests/test_dominator.py
+++ b/lib/lp/archivepublisher/tests/test_dominator.py
@@ -41,6 +41,7 @@ from lp.testing import (
StormStatementRecorder,
TestCaseWithFactory,
)
+from lp.testing.dbuser import lp_dbuser
from lp.testing.fakemethod import FakeMethod
from lp.testing.layers import ZopelessDatabaseLayer
from lp.testing.matchers import HasQueryCount
@@ -165,6 +166,8 @@ class TestDominator(TestNativePublishingBase):
def test_dominateBinaries_rejects_empty_publication_list(self):
"""Domination asserts for non-empty input list."""
+ with lp_dbuser():
+ distroseries = self.factory.makeDistroArchSeries().distroseries
package = self.factory.makeBinaryPackageName()
dominator = Dominator(self.logger, self.ubuntutest.main_archive)
dominator._sortPackages = FakeMethod({package.name: []})
@@ -173,11 +176,12 @@ class TestDominator(TestNativePublishingBase):
self.assertRaises(
AssertionError,
dominator.dominateBinaries,
- self.factory.makeDistroArchSeries().distroseries,
- self.factory.getAnyPocket())
+ distroseries, self.factory.getAnyPocket())
def test_dominateSources_rejects_empty_publication_list(self):
"""Domination asserts for non-empty input list."""
+ with lp_dbuser():
+ distroseries = self.factory.makeDistroSeries()
package = self.factory.makeSourcePackageName()
dominator = Dominator(self.logger, self.ubuntutest.main_archive)
dominator._sortPackages = FakeMethod({package.name: []})
@@ -186,7 +190,7 @@ class TestDominator(TestNativePublishingBase):
self.assertRaises(
AssertionError,
dominator.dominateSources,
- self.factory.makeDistroSeries(), self.factory.getAnyPocket())
+ distroseries, self.factory.getAnyPocket())
def test_archall_domination(self):
# Arch-all binaries should not be dominated when a new source
diff --git a/lib/lp/archivepublisher/tests/test_publisher.py b/lib/lp/archivepublisher/tests/test_publisher.py
index c0891dd..754138b 100644
--- a/lib/lp/archivepublisher/tests/test_publisher.py
+++ b/lib/lp/archivepublisher/tests/test_publisher.py
@@ -1288,7 +1288,8 @@ class TestPublisher(TestPublisherBase):
ubuntu = getUtility(IDistributionSet)['ubuntu']
ppa = self.factory.makeArchive(purpose=ArchivePurpose.PPA)
- copy_archive = self.factory.makeArchive(purpose=ArchivePurpose.COPY)
+ copy_archive = self.factory.makeArchive(
+ distribution=self.ubuntu, purpose=ArchivePurpose.COPY)
self.assertNotIn(ppa, ubuntu.getPendingPublicationPPAs())
self.assertNotIn(copy_archive, ubuntu.getPendingPublicationPPAs())
ppa.status = ArchiveStatus.DELETING
diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml
index fbeaffd..10d677f 100644
--- a/lib/lp/registry/configure.zcml
+++ b/lib/lp/registry/configure.zcml
@@ -1821,7 +1821,8 @@
class="lp.registry.model.distribution.Distribution">
<allow
interface="lp.registry.interfaces.distribution.IDistributionPublic
- lp.bugs.interfaces.bugsummary.IBugSummaryDimension"/>
+ lp.bugs.interfaces.bugsummary.IBugSummaryDimension"
+ attributes="information_type"/>
<require
permission="launchpad.LimitedView"
interface="lp.registry.interfaces.distribution.IDistributionLimitedView"/>
@@ -1830,7 +1831,8 @@
interface="lp.registry.interfaces.distribution.IDistributionView"/>
<require
permission="launchpad.Edit"
- interface="lp.registry.interfaces.distribution.IDistributionEditRestricted"/>
+ interface="lp.registry.interfaces.distribution.IDistributionEditRestricted"
+ set_schema="lp.app.interfaces.informationtype.IInformationType"/>
<require
permission="launchpad.Edit"
set_attributes="answers_usage blueprints_usage codehosting_usage
diff --git a/lib/lp/registry/errors.py b/lib/lp/registry/errors.py
index 4e1c52f..09acb08 100644
--- a/lib/lp/registry/errors.py
+++ b/lib/lp/registry/errors.py
@@ -27,7 +27,7 @@ __all__ = [
'InclusiveTeamLinkageError',
'PPACreationError',
'PrivatePersonLinkageError',
- 'ProprietaryProduct',
+ 'ProprietaryPillar',
'TeamMembershipTransitionError',
'TeamMembershipPolicyError',
'UserCannotChangeMembershipSilently',
@@ -100,8 +100,8 @@ class CommercialSubscribersOnly(Unauthorized):
"""
-class ProprietaryProduct(Exception):
- """Cannot make the change because the project is proprietary."""
+class ProprietaryPillar(Exception):
+ """Cannot make the change because the pillar is proprietary."""
class NoSuchSourcePackageName(NameLookupFailed):
diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py
index b102de8..7e943c1 100644
--- a/lib/lp/registry/interfaces/distribution.py
+++ b/lib/lp/registry/interfaces/distribution.py
@@ -59,6 +59,7 @@ from lp import _
from lp.answers.interfaces.faqtarget import IFAQTarget
from lp.answers.interfaces.questiontarget import IQuestionTarget
from lp.app.errors import NameLookupFailed
+from lp.app.interfaces.informationtype import IInformationType
from lp.app.interfaces.launchpad import (
IHasIcon,
IHasLogo,
@@ -163,6 +164,14 @@ class IDistributionPublic(Interface):
def userCanLimitedView(user):
"""True if the given user has limited access to this distribution."""
+ private = exported(
+ Bool(
+ title=_("Distribution is confidential"),
+ required=False, readonly=True, default=False,
+ description=_(
+ "If set, this distribution is visible only to those with "
+ "access grants.")))
+
class IDistributionLimitedView(IHasIcon, IHasLogo, IHasOwner, ILaunchpadUsage):
"""IDistribution attributes visible to people with artifact grants."""
@@ -456,6 +465,9 @@ class IDistributionView(
"An object which contains the timeframe and the voucher code of a "
"subscription.")))
+ has_current_commercial_subscription = Attribute(
+ "Whether the distribution has a current commercial subscription.")
+
def getArchiveIDList(archive=None):
"""Return a list of archive IDs suitable for sqlvalues() or quote().
@@ -768,6 +780,14 @@ class IDistributionView(
class IDistributionEditRestricted(IOfficialBugTagTargetRestricted):
"""IDistribution properties requiring launchpad.Edit permission."""
+ def checkInformationType(value):
+ """Check whether the information type change should be permitted.
+
+ Iterate through exceptions explaining why the type should not be
+ changed. Has the side-effect of creating a commercial subscription
+ if permitted.
+ """
+
@call_with(registrant=REQUEST_USER)
@operation_parameters(
registry_url=TextLine(
@@ -802,7 +822,8 @@ class IDistributionEditRestricted(IOfficialBugTagTargetRestricted):
class IDistribution(
IDistributionEditRestricted, IDistributionPublic,
IDistributionLimitedView, IDistributionView, IHasBugSupervisor,
- IFAQTarget, IQuestionTarget, IStructuralSubscriptionTarget):
+ IFAQTarget, IQuestionTarget, IStructuralSubscriptionTarget,
+ IInformationType):
"""An operating system distribution.
Launchpadlib example: retrieving the current version of a package in a
@@ -853,7 +874,8 @@ class IDistributionSet(Interface):
"""Return the IDistribution with the given name or None."""
def new(name, display_name, title, description, summary, domainname,
- members, owner, registrant, mugshot=None, logo=None, icon=None):
+ members, owner, registrant, mugshot=None, logo=None, icon=None,
+ information_type=None):
"""Create a new distribution."""
def getCurrentSourceReleases(distro_to_source_packagenames):
diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py
index c6327d2..94558ca 100644
--- a/lib/lp/registry/model/distribution.py
+++ b/lib/lp/registry/model/distribution.py
@@ -9,11 +9,17 @@ __all__ = [
]
from collections import defaultdict
+from datetime import (
+ datetime,
+ timedelta,
+ )
import itertools
from operator import itemgetter
+import pytz
from storm.expr import (
And,
+ Coalesce,
Desc,
Exists,
Join,
@@ -27,6 +33,7 @@ from storm.expr import (
from storm.info import ClassAlias
from storm.locals import (
Int,
+ List,
Reference,
)
from storm.store import Store
@@ -40,15 +47,23 @@ from lp.answers.model.faq import (
FAQSearch,
)
from lp.answers.model.question import (
+ Question,
QuestionTargetMixin,
QuestionTargetSearch,
)
from lp.app.enums import (
FREE_INFORMATION_TYPES,
InformationType,
+ PILLAR_INFORMATION_TYPES,
+ PRIVATE_INFORMATION_TYPES,
+ PROPRIETARY_INFORMATION_TYPES,
+ PUBLIC_INFORMATION_TYPES,
ServiceUsage,
)
-from lp.app.errors import NotFoundError
+from lp.app.errors import (
+ NotFoundError,
+ ServiceUsageForbidden,
+ )
from lp.app.interfaces.launchpad import (
IHasIcon,
IHasLogo,
@@ -57,11 +72,14 @@ from lp.app.interfaces.launchpad import (
ILaunchpadUsage,
IServiceUsage,
)
+from lp.app.interfaces.services import IService
+from lp.app.model.launchpad import InformationTypeMixin
from lp.app.validators.name import (
sanitize_name,
valid_name,
)
from lp.archivepublisher.debversion import Version
+from lp.blueprints.enums import SpecificationFilter
from lp.blueprints.model.specification import (
HasSpecificationsMixin,
Specification,
@@ -75,12 +93,14 @@ from lp.bugs.model.bugtarget import (
BugTargetBase,
OfficialBugTagTargetMixin,
)
+from lp.bugs.model.bugtaskflat import BugTaskFlat
from lp.bugs.model.structuralsubscription import (
StructuralSubscriptionTargetMixin,
)
from lp.code.interfaces.seriessourcepackagebranch import (
IFindOfficialBranchLinks,
)
+from lp.code.model.branch import Branch
from lp.oci.interfaces.ociregistrycredentials import (
IOCIRegistryCredentialsSet,
)
@@ -88,11 +108,20 @@ from lp.registry.enums import (
BranchSharingPolicy,
BugSharingPolicy,
DistributionDefaultTraversalPolicy,
+ INCLUSIVE_TEAM_POLICY,
SpecificationSharingPolicy,
VCSType,
)
-from lp.registry.errors import NoSuchDistroSeries
-from lp.registry.interfaces.accesspolicy import IAccessPolicySource
+from lp.registry.errors import (
+ CannotChangeInformationType,
+ CommercialSubscribersOnly,
+ NoSuchDistroSeries,
+ ProprietaryPillar,
+ )
+from lp.registry.interfaces.accesspolicy import (
+ IAccessPolicyGrantSource,
+ IAccessPolicySource,
+ )
from lp.registry.interfaces.distribution import (
IDistribution,
IDistributionSet,
@@ -119,6 +148,7 @@ from lp.registry.interfaces.pocket import suffixpocket
from lp.registry.interfaces.role import IPersonRoles
from lp.registry.interfaces.series import SeriesStatus
from lp.registry.interfaces.sourcepackagename import ISourcePackageName
+from lp.registry.model.accesspolicy import AccessPolicyGrantFlat
from lp.registry.model.announcement import MakesAnnouncements
from lp.registry.model.commercialsubscription import CommercialSubscription
from lp.registry.model.distributionmirror import (
@@ -142,6 +172,7 @@ from lp.registry.model.ociprojectname import OCIProjectName
from lp.registry.model.oopsreferences import referenced_oops
from lp.registry.model.pillar import HasAliasMixin
from lp.registry.model.sourcepackagename import SourcePackageName
+from lp.registry.model.teammembership import TeamParticipation
from lp.services.database.bulk import load_referencing
from lp.services.database.constants import UTC_NOW
from lp.services.database.datetimecol import UtcDateTimeCol
@@ -159,6 +190,8 @@ from lp.services.database.sqlobject import (
StringCol,
)
from lp.services.database.stormexpr import (
+ ArrayAgg,
+ ArrayIntersects,
fti_search,
rank_by_fti,
)
@@ -203,6 +236,7 @@ from lp.translations.enums import TranslationPermission
from lp.translations.model.hastranslationimports import (
HasTranslationImportsMixin,
)
+from lp.translations.model.potemplate import POTemplate
from lp.translations.model.translationpolicy import TranslationPolicyMixin
@@ -215,7 +249,8 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
HasTranslationImportsMixin, KarmaContextMixin,
OfficialBugTagTargetMixin, QuestionTargetMixin,
StructuralSubscriptionTargetMixin, HasMilestonesMixin,
- HasDriversMixin, TranslationPolicyMixin):
+ HasDriversMixin, TranslationPolicyMixin,
+ InformationTypeMixin):
"""A distribution of an operating system, e.g. Debian GNU/Linux."""
_table = 'Distribution'
@@ -281,9 +316,12 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
oci_registry_credentials = Reference(
oci_registry_credentials_id, "OCIRegistryCredentials.id")
+ _creating = False
+
def __init__(self, name, display_name, title, description, summary,
domainname, members, owner, registrant, mugshot=None,
- logo=None, icon=None, vcs=None):
+ logo=None, icon=None, vcs=None, information_type=None):
+ self._creating = True
try:
self.name = name
self.display_name = display_name
@@ -299,9 +337,11 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
self.logo = logo
self.icon = icon
self.vcs = vcs
+ self.information_type = information_type
except Exception:
IStore(self).remove(self)
raise
+ del self._creating
def __repr__(self):
display_name = backslashreplace(self.display_name)
@@ -321,6 +361,109 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
"""See `IBugTarget`."""
return self
+ def _valid_distribution_information_type(self, attr, value):
+ for exception in self.checkInformationType(value):
+ raise exception
+ return value
+
+ def checkInformationType(self, value):
+ """See `IDistribution`."""
+ if value not in PILLAR_INFORMATION_TYPES:
+ yield CannotChangeInformationType(
+ 'Not supported for distributions.')
+ if value in PROPRIETARY_INFORMATION_TYPES:
+ if self.answers_usage == ServiceUsage.LAUNCHPAD:
+ yield CannotChangeInformationType('Answers is enabled.')
+ if self._creating or value not in PROPRIETARY_INFORMATION_TYPES:
+ return
+ # Additional checks when transitioning an existing distribution to a
+ # proprietary type.
+ # All specs located by an ALL search are public.
+ public_specs = self.specifications(
+ None, filter=[SpecificationFilter.ALL])
+ if not public_specs.is_empty():
+ # Unlike bugs and branches, specifications cannot be USERDATA or a
+ # security type.
+ yield CannotChangeInformationType('Some blueprints are public.')
+ store = Store.of(self)
+ series_ids = [series.id for series in self.series]
+ non_proprietary_bugs = store.find(
+ BugTaskFlat,
+ BugTaskFlat.information_type.is_in(FREE_INFORMATION_TYPES),
+ Or(
+ BugTaskFlat.distribution == self.id,
+ BugTaskFlat.distroseries_id.is_in(series_ids)))
+ if not non_proprietary_bugs.is_empty():
+ yield CannotChangeInformationType(
+ 'Some bugs are neither proprietary nor embargoed.')
+ # Default returns all public branches.
+ non_proprietary_branches = store.find(
+ Branch,
+ DistroSeries.distribution == self.id,
+ Branch.distroseries == DistroSeries.id,
+ Not(Branch.information_type.is_in(PROPRIETARY_INFORMATION_TYPES)))
+ if not non_proprietary_branches.is_empty():
+ yield CannotChangeInformationType(
+ 'Some branches are neither proprietary nor embargoed.')
+ questions = store.find(Question, Question.distribution == self.id)
+ if not questions.is_empty():
+ yield CannotChangeInformationType(
+ 'This distribution has questions.')
+ templates = store.find(
+ POTemplate, DistroSeries.distribution == self.id,
+ POTemplate.distroseries == DistroSeries.id)
+ if not templates.is_empty():
+ yield CannotChangeInformationType(
+ 'This distribution has translations.')
+ if not self.getTranslationImportQueueEntries().is_empty():
+ yield CannotChangeInformationType(
+ 'This distribution has queued translations.')
+ if self.translations_usage == ServiceUsage.LAUNCHPAD:
+ yield CannotChangeInformationType('Translations are enabled.')
+ bug_supervisor = self.bug_supervisor
+ if (bug_supervisor is not None and
+ bug_supervisor.membership_policy in INCLUSIVE_TEAM_POLICY):
+ yield CannotChangeInformationType(
+ 'Bug supervisor has inclusive membership.')
+
+ # Proprietary check works only after creation, because during
+ # creation, has_current_commercial_subscription cannot give the
+ # right value and triggers an inappropriate DB flush.
+
+ # Create the complimentary commercial subscription for the
+ # distribution.
+ self._ensure_complimentary_subscription()
+
+ # If you have a commercial subscription, but it's not current, you
+ # cannot set the information type to a PROPRIETARY type.
+ if not self.has_current_commercial_subscription:
+ yield CommercialSubscribersOnly(
+ 'A valid commercial subscription is required for private'
+ ' distributions.')
+
+ _information_type = DBEnum(
+ enum=InformationType, default=InformationType.PUBLIC,
+ name="information_type",
+ validator=_valid_distribution_information_type)
+
+ @property
+ def information_type(self):
+ return self._information_type or InformationType.PUBLIC
+
+ @information_type.setter
+ def information_type(self, value):
+ old_info_type = self._information_type
+ self._information_type = value
+ # Make sure that policies are updated to grant permission to the
+ # maintainer as required for the Distribution.
+ # However, only on edits. If this is a new Distribution it's
+ # handled already.
+ if not self._creating:
+ if (old_info_type == InformationType.PUBLIC and
+ value != InformationType.PUBLIC):
+ self._ensure_complimentary_subscription()
+ self._ensurePolicies([value])
+
@property
def pillar_category(self):
"""See `IPillar`."""
@@ -344,12 +487,77 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
# Sharing policy for distributions is always PUBLIC.
return SpecificationSharingPolicy.PUBLIC
+ # Cache of AccessPolicy.ids that convey launchpad.LimitedView.
+ # Unlike artifacts' cached access_policies, an AccessArtifactGrant
+ # to an artifact in the policy is sufficient for access.
+ access_policies = List(type=Int())
+
+ def _ensurePolicies(self, information_types):
+ # Ensure that the distribution has access policies for the specified
+ # information types.
+ aps = getUtility(IAccessPolicySource)
+ existing_policies = aps.findByPillar([self])
+ existing_types = {
+ access_policy.type for access_policy in existing_policies}
+ # Create the missing policies.
+ required_types = set(information_types).difference(
+ existing_types).intersection(PRIVATE_INFORMATION_TYPES)
+ policies = itertools.product((self,), required_types)
+ policies = getUtility(IAccessPolicySource).create(policies)
+
+ # Add the maintainer to the policies.
+ grants = []
+ for p in policies:
+ grants.append((p, self.owner, self.owner))
+ getUtility(IAccessPolicyGrantSource).grant(grants)
+
+ self._cacheAccessPolicies()
+
+ def _cacheAccessPolicies(self):
+ # Update the cache of AccessPolicy.ids for which an
+ # AccessPolicyGrant or AccessArtifactGrant is sufficient to
+ # convey launchpad.LimitedView on this Distribution.
+ #
+ # We only need a cache for proprietary types, and it only
+ # includes proprietary policies in case a policy like Private
+ # Security was somehow left around when a project was
+ # transitioned to Proprietary.
+ if self.information_type in PROPRIETARY_INFORMATION_TYPES:
+ self.access_policies = [
+ policy.id for policy in
+ getUtility(IAccessPolicySource).find(
+ [(self, type) for type in PROPRIETARY_INFORMATION_TYPES])]
+ else:
+ self.access_policies = None
+
@cachedproperty
def commercial_subscription(self):
return IStore(CommercialSubscription).find(
CommercialSubscription, distribution=self).one()
@property
+ def has_current_commercial_subscription(self):
+ now = datetime.now(pytz.UTC)
+ return (self.commercial_subscription
+ and self.commercial_subscription.date_expires > now)
+
+ def _ensure_complimentary_subscription(self):
+ """Create a complementary commercial subscription for the distro."""
+ if not self.commercial_subscription:
+ lp_janitor = getUtility(ILaunchpadCelebrities).janitor
+ now = datetime.now(pytz.UTC)
+ date_expires = now + timedelta(days=30)
+ sales_system_id = "complimentary-30-day-%s" % now
+ whiteboard = (
+ "Complimentary 30 day subscription. -- Launchpad %s" %
+ now.date().isoformat())
+ subscription = CommercialSubscription(
+ pillar=self, date_starts=now, date_expires=date_expires,
+ registrant=lp_janitor, purchaser=lp_janitor,
+ sales_system_id=sales_system_id, whiteboard=whiteboard)
+ get_property_cache(self).commercial_subscription = subscription
+
+ @property
def uploaders(self):
"""See `IDistribution`."""
# Get all the distribution archives and find out the uploaders
@@ -397,6 +605,10 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
return self._answers_usage
def _set_answers_usage(self, val):
+ if val == ServiceUsage.LAUNCHPAD:
+ if self.information_type in PROPRIETARY_INFORMATION_TYPES:
+ raise ServiceUsageForbidden(
+ "Answers not allowed for non-public distributions.")
self._answers_usage = val
if val == ServiceUsage.LAUNCHPAD:
self.official_answers = True
@@ -432,9 +644,17 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
_set_blueprints_usage,
doc="Indicates if the product uses the blueprints service.")
+ def validate_translations_usage(self, attr, value):
+ if value == ServiceUsage.LAUNCHPAD and self.private:
+ raise ProprietaryPillar(
+ "Translations are not supported for proprietary "
+ "distributions.")
+ return value
+
translations_usage = DBEnum(
name="translations_usage", allow_none=False,
- enum=ServiceUsage, default=ServiceUsage.UNKNOWN)
+ enum=ServiceUsage, default=ServiceUsage.UNKNOWN,
+ validator=validate_translations_usage)
@property
def codehosting_usage(self):
@@ -1568,17 +1788,41 @@ class Distribution(SQLBase, BugTargetBase, MakesAnnouncements,
self.oci_registry_credentials = None
old_credentials.destroySelf()
+ @cachedproperty
+ def _known_viewers(self):
+ """A set of known persons able to view this distribution."""
+ return set()
+
def userCanView(self, user):
"""See `IDistributionPublic`."""
- # All distributions are public until we finish introducing privacy
- # support.
- return True
+ if self.information_type in PUBLIC_INFORMATION_TYPES:
+ return True
+ if user is None:
+ return False
+ if user.id in self._known_viewers:
+ return True
+ if not IPersonRoles.providedBy(user):
+ user = IPersonRoles(user)
+ if user.in_commercial_admin or user.in_admin:
+ self._known_viewers.add(user.id)
+ return True
+ if getUtility(IService, 'sharing').checkPillarAccess(
+ [self], self.information_type, user):
+ self._known_viewers.add(user.id)
+ return True
+ return False
def userCanLimitedView(self, user):
"""See `IDistributionPublic`."""
- # All distributions are public until we finish introducing privacy
- # support.
- return True
+ if self.userCanView(user):
+ return True
+ if user is None:
+ return False
+ return not Store.of(self).find(
+ Distribution,
+ Distribution.id == self.id,
+ DistributionSet.getDistributionPrivacyFilter(user.person),
+ ).is_empty()
@implementer(IDistributionSet)
@@ -1618,10 +1862,48 @@ class DistributionSet:
return None
return pillar
+ @staticmethod
+ def getDistributionPrivacyFilter(user):
+ # Anonymous users can only see public distributions. This is also
+ # sometimes used with an outer join with e.g. Product, so we let
+ # NULL through too.
+ public_filter = Or(
+ Distribution._information_type == None,
+ Distribution._information_type == InformationType.PUBLIC)
+ if user is None:
+ return public_filter
+
+ # (Commercial) admins can see any project.
+ roles = IPersonRoles(user)
+ if roles.in_admin or roles.in_commercial_admin:
+ return True
+
+ # Normal users can see any project for which they can see either
+ # an entire policy or an artifact.
+ # XXX wgrant 2015-06-26: This is slower than ideal for people in
+ # teams with lots of artifact grants, as there can be tens of
+ # thousands of APGF rows for a single policy. But it's tens of
+ # milliseconds at most.
+ grant_filter = Coalesce(
+ ArrayIntersects(
+ SQL('Distribution.access_policies'),
+ Select(
+ ArrayAgg(AccessPolicyGrantFlat.policy_id),
+ tables=(AccessPolicyGrantFlat,
+ Join(TeamParticipation,
+ TeamParticipation.teamID ==
+ AccessPolicyGrantFlat.grantee_id)),
+ where=(TeamParticipation.person == user)
+ )),
+ False)
+ return Or(public_filter, grant_filter)
+
def new(self, name, display_name, title, description, summary, domainname,
members, owner, registrant, mugshot=None, logo=None, icon=None,
- vcs=None):
+ vcs=None, information_type=None):
"""See `IDistributionSet`."""
+ if information_type is None:
+ information_type = InformationType.PUBLIC
distro = Distribution(
name=name,
display_name=display_name,
@@ -1635,14 +1917,21 @@ class DistributionSet:
mugshot=mugshot,
logo=logo,
icon=icon,
- vcs=vcs)
+ vcs=vcs,
+ information_type=information_type)
IStore(distro).add(distro)
- getUtility(IArchiveSet).new(distribution=distro,
- owner=owner, purpose=ArchivePurpose.PRIMARY)
- policies = itertools.product(
- (distro,), (InformationType.USERDATA,
- InformationType.PRIVATESECURITY))
- getUtility(IAccessPolicySource).create(policies)
+ if information_type != InformationType.PUBLIC:
+ distro._ensure_complimentary_subscription()
+ # XXX cjwatson 2022-02-10: Replace this with sharing policies once
+ # those are defined here.
+ distro._ensurePolicies(
+ [information_type]
+ if information_type == InformationType.PROPRIETARY
+ else FREE_INFORMATION_TYPES)
+ if information_type == InformationType.PUBLIC:
+ getUtility(IArchiveSet).new(
+ distribution=distro, owner=owner,
+ purpose=ArchivePurpose.PRIMARY)
return distro
def getCurrentSourceReleases(self, distro_source_packagenames):
diff --git a/lib/lp/registry/model/person.py b/lib/lp/registry/model/person.py
index b544ae3..1d5f2be 100644
--- a/lib/lp/registry/model/person.py
+++ b/lib/lp/registry/model/person.py
@@ -1158,6 +1158,7 @@ class Person(
from lp.registry.model.commercialsubscription import (
CommercialSubscription,
)
+ from lp.registry.model.distribution import Distribution
from lp.registry.model.person import Person
from lp.registry.model.product import Product
from lp.registry.model.teammembership import TeamParticipation
@@ -1166,11 +1167,16 @@ class Person(
Join(
TeamParticipation,
Person.id == TeamParticipation.personID),
- Join(
+ LeftJoin(
Product, TeamParticipation.teamID == Product._ownerID),
+ LeftJoin(
+ Distribution,
+ TeamParticipation.teamID == Distribution.ownerID),
Join(
CommercialSubscription,
- CommercialSubscription.product_id == Product.id)
+ Or(
+ CommercialSubscription.product_id == Product.id,
+ CommercialSubscription.distribution_id == Distribution.id))
).find(
Person,
CommercialSubscription.date_expires > datetime.now(
diff --git a/lib/lp/registry/model/product.py b/lib/lp/registry/model/product.py
index 0d9235c..8e8c0f2 100644
--- a/lib/lp/registry/model/product.py
+++ b/lib/lp/registry/model/product.py
@@ -128,7 +128,7 @@ from lp.registry.enums import (
from lp.registry.errors import (
CannotChangeInformationType,
CommercialSubscribersOnly,
- ProprietaryProduct,
+ ProprietaryPillar,
)
from lp.registry.interfaces.accesspolicy import (
IAccessPolicyArtifactSource,
@@ -560,7 +560,7 @@ class Product(SQLBase, BugTargetBase, MakesAnnouncements,
def validate_translations_usage(self, attr, value):
if value == ServiceUsage.LAUNCHPAD and self.private:
- raise ProprietaryProduct(
+ raise ProprietaryPillar(
"Translations are not supported for proprietary products.")
return value
@@ -683,7 +683,7 @@ class Product(SQLBase, BugTargetBase, MakesAnnouncements,
"proprietary %s." % kind)
if self.information_type != InformationType.PUBLIC:
if InformationType.PUBLIC in allowed_types[var]:
- raise ProprietaryProduct(
+ raise ProprietaryPillar(
"The project is %s." % self.information_type.title)
self._ensurePolicies(allowed_types[var])
diff --git a/lib/lp/registry/model/productrelease.py b/lib/lp/registry/model/productrelease.py
index a1ae8fd..ba96c73 100644
--- a/lib/lp/registry/model/productrelease.py
+++ b/lib/lp/registry/model/productrelease.py
@@ -26,7 +26,7 @@ from lp.app.enums import InformationType
from lp.app.errors import NotFoundError
from lp.registry.errors import (
InvalidFilename,
- ProprietaryProduct,
+ ProprietaryPillar,
)
from lp.registry.interfaces.person import (
validate_person,
@@ -151,7 +151,7 @@ class ProductRelease(SQLBase):
description=None, from_api=False):
"""See `IProductRelease`."""
if not self.can_have_release_files:
- raise ProprietaryProduct(
+ raise ProprietaryPillar(
"Only public projects can have download files.")
if self.hasReleaseFile(filename):
raise InvalidFilename
diff --git a/lib/lp/registry/model/productseries.py b/lib/lp/registry/model/productseries.py
index cf28a82..772373d 100644
--- a/lib/lp/registry/model/productseries.py
+++ b/lib/lp/registry/model/productseries.py
@@ -43,7 +43,7 @@ from lp.bugs.model.bugtarget import BugTargetBase
from lp.bugs.model.structuralsubscription import (
StructuralSubscriptionTargetMixin,
)
-from lp.registry.errors import ProprietaryProduct
+from lp.registry.errors import ProprietaryPillar
from lp.registry.interfaces.packaging import PackagingType
from lp.registry.interfaces.person import validate_person
from lp.registry.interfaces.productrelease import IProductReleaseSet
@@ -141,8 +141,8 @@ class ProductSeries(SQLBase, BugTargetBase, HasMilestonesMixin,
return value
if (self.product.private and
value != TranslationsBranchImportMode.NO_IMPORT):
- raise ProprietaryProduct('Translations are disabled for'
- ' proprietary projects.')
+ raise ProprietaryPillar('Translations are disabled for'
+ ' proprietary projects.')
return value
translations_autoimport_mode = DBEnum(
diff --git a/lib/lp/registry/services/sharingservice.py b/lib/lp/registry/services/sharingservice.py
index dda0934..9480512 100644
--- a/lib/lp/registry/services/sharingservice.py
+++ b/lib/lp/registry/services/sharingservice.py
@@ -187,7 +187,13 @@ class SharingService:
def getSharedDistributions(self, person, user):
"""See `ISharingService`."""
- return self._getSharedPillars(person, user, Distribution)
+ commercial_filter = None
+ if user and IPersonRoles(user).in_commercial_admin:
+ commercial_filter = Exists(Select(
+ 1, tables=CommercialSubscription,
+ where=CommercialSubscription.distribution == Distribution.id))
+ return self._getSharedPillars(
+ person, user, Distribution, commercial_filter)
def getArtifactGrantsForPersonOnPillar(self, pillar, person):
"""Return the artifact grants for the given person and pillar."""
diff --git a/lib/lp/registry/services/tests/test_sharingservice.py b/lib/lp/registry/services/tests/test_sharingservice.py
index e7444fe..8ce3be1 100644
--- a/lib/lp/registry/services/tests/test_sharingservice.py
+++ b/lib/lp/registry/services/tests/test_sharingservice.py
@@ -93,8 +93,7 @@ class PillarScenariosMixin(WithScenarios):
def _makePillar(self, **kwargs):
if ("bug_sharing_policy" in kwargs or
"branch_sharing_policy" in kwargs or
- "specification_sharing_policy" in kwargs or
- "information_type" in kwargs):
+ "specification_sharing_policy" in kwargs):
self._skipUnlessProduct()
return getattr(self.factory, self.pillar_factory_name)(**kwargs)
@@ -215,7 +214,6 @@ class TestSharingService(
[InformationType.PRIVATESECURITY, InformationType.USERDATA])
def test_getInformationTypes_expired_commercial(self):
- self._skipUnlessProduct()
pillar = self._makePillar()
self.factory.makeCommercialSubscription(pillar, expired=True)
self._assert_getAllowedInformationTypes(
@@ -268,6 +266,7 @@ class TestSharingService(
def test_getBranchSharingPolicies_non_public(self):
# When the pillar is non-public the policy options are limited to
# only proprietary or embargoed/proprietary.
+ self._skipUnlessProduct()
owner = self.factory.makePerson()
pillar = self._makePillar(
information_type=InformationType.PROPRIETARY,
@@ -334,6 +333,7 @@ class TestSharingService(
def test_getSpecificationSharingPolicies_non_public(self):
# When the pillar is non-public the policy options are limited to
# only proprietary or embargoed/proprietary.
+ self._skipUnlessProduct()
owner = self.factory.makePerson()
pillar = self._makePillar(
information_type=InformationType.PROPRIETARY,
@@ -386,6 +386,7 @@ class TestSharingService(
def test_getBugSharingPolicies_non_public(self):
# When the pillar is non-public the policy options are limited to
# only proprietary or embargoed/proprietary.
+ self._skipUnlessProduct()
owner = self.factory.makePerson()
pillar = self._makePillar(
information_type=InformationType.PROPRIETARY,
@@ -477,14 +478,13 @@ class TestSharingService(
self._makeGranteeData(
artifact_grant.grantee,
[(InformationType.PROPRIETARY, SharingPermission.SOME)],
- [InformationType.PROPRIETARY])]
- if IProduct.providedBy(pillar):
- owner_data = self._makeGranteeData(
+ [InformationType.PROPRIETARY]),
+ self._makeGranteeData(
pillar.owner,
[(InformationType.USERDATA, SharingPermission.ALL),
(InformationType.PRIVATESECURITY, SharingPermission.ALL)],
- [])
- expected_grantees.append(owner_data)
+ []),
+ ]
self.assertContentEqual(expected_grantees, grantees)
def test_getPillarGranteeData(self):
@@ -503,7 +503,6 @@ class TestSharingService(
Steps 2 and 3 are split out to allow batching on persons.
"""
- self._skipUnlessProduct()
driver = self.factory.makePerson()
pillar = self._makePillar(driver=driver)
login_person(driver)
@@ -577,19 +576,15 @@ class TestSharingService(
artifact=artifact_grant.abstract_artifact, policy=access_policy)
grantees = self.service.getPillarGrantees(pillar)
+ policies = getUtility(IAccessPolicySource).findByPillar([pillar])
+ policies = [policy for policy in policies
+ if policy.type != InformationType.PROPRIETARY]
expected_grantees = [
(grantee, {access_policy: SharingPermission.ALL}, []),
(artifact_grant.grantee, {access_policy: SharingPermission.SOME},
- [access_policy.type])]
- if IProduct.providedBy(pillar):
- policies = getUtility(IAccessPolicySource).findByPillar([pillar])
- policies = [policy for policy in policies
- if policy.type != InformationType.PROPRIETARY]
- owner_data = (
- pillar.owner,
- dict.fromkeys(policies, SharingPermission.ALL),
- [])
- expected_grantees.append(owner_data)
+ [access_policy.type]),
+ (pillar.owner, dict.fromkeys(policies, SharingPermission.ALL), []),
+ ]
self.assertContentEqual(expected_grantees, grantees)
def test_getPillarGrantees(self):
@@ -703,22 +698,13 @@ class TestSharingService(
self.assertContentEqual(
expected_grantee_data, grantee_data['grantee_entry'])
# Check that getPillarGrantees returns what we expect.
- if IProduct.providedBy(pillar):
- expected_grantee_grants = [
- (grantee,
- {ud_policy: SharingPermission.SOME,
- es_policy: SharingPermission.ALL},
- [InformationType.PRIVATESECURITY,
- InformationType.USERDATA]),
- ]
- else:
- expected_grantee_grants = [
- (grantee,
- {es_policy: SharingPermission.ALL,
- ud_policy: SharingPermission.SOME},
- [InformationType.PRIVATESECURITY,
- InformationType.USERDATA]),
- ]
+ expected_grantee_grants = [
+ (grantee,
+ {ud_policy: SharingPermission.SOME,
+ es_policy: SharingPermission.ALL},
+ [InformationType.PRIVATESECURITY,
+ InformationType.USERDATA]),
+ ]
grantee_grants = list(self.service.getPillarGrantees(pillar))
# Again, filter out the owner, if one exists.
@@ -842,11 +828,10 @@ class TestSharingService(
yet_another, policy_permissions,
[InformationType.PRIVATESECURITY, InformationType.USERDATA])
expected_data.append(yet_another_person_data)
- if IProduct.providedBy(pillar):
- policy_permissions = {
- policy: SharingPermission.ALL for policy in access_policies}
- owner_data = (pillar.owner, policy_permissions, [])
- expected_data.append(owner_data)
+ policy_permissions = {
+ policy: SharingPermission.ALL for policy in access_policies}
+ owner_data = (pillar.owner, policy_permissions, [])
+ expected_data.append(owner_data)
self._assert_grantee_data(
expected_data, self.service.getPillarGrantees(pillar))
@@ -1516,8 +1501,9 @@ class TestSharingService(
grant_access(branch, i == 9)
for i, gitrepository in enumerate(gitrepositories):
grant_access(gitrepository, i == 9)
- getUtility(IService, 'sharing').ensureAccessGrants(
- [grantee], pillar.owner, snaps=snaps[:9])
+ if snaps:
+ getUtility(IService, 'sharing').ensureAccessGrants(
+ [grantee], pillar.owner, snaps=snaps[:9])
getUtility(IService, 'sharing').ensureAccessGrants(
[grantee], pillar.owner, specifications=specs[:9])
getUtility(IService, 'sharing').ensureAccessGrants(
@@ -1593,7 +1579,6 @@ class TestSharingService(
def test_getSharedPillars_commercial_admin_current(self):
# Commercial admins can see all current commercial pillars.
- self._skipUnlessProduct()
admin = getUtility(ILaunchpadCelebrities).commercial_admin.teamowner
pillar = self._makePillar()
self.factory.makeCommercialSubscription(pillar)
@@ -1601,7 +1586,6 @@ class TestSharingService(
def test_getSharedPillars_commercial_admin_expired(self):
# Commercial admins can see all expired commercial pillars.
- self._skipUnlessProduct()
admin = getUtility(ILaunchpadCelebrities).commercial_admin.teamowner
pillar = self._makePillar()
self.factory.makeCommercialSubscription(pillar, expired=True)
@@ -1684,6 +1668,7 @@ class TestSharingService(
def test_getSharedSnaps(self):
# Test the getSharedSnaps method.
+ self._skipUnlessProduct()
owner = self.factory.makePerson()
pillar = self._makePillar(
owner=owner, specification_sharing_policy=(
@@ -1981,7 +1966,6 @@ class TestSharingService(
def test_getAccessPolicyGrantCounts(self):
# checkPillarAccess checks whether the user has full access to
# an information type.
- self._skipUnlessProduct()
pillar = self._makePillar()
grantee = self.factory.makePerson()
with admin_logged_in():
diff --git a/lib/lp/registry/stories/webservice/xx-distribution.txt b/lib/lp/registry/stories/webservice/xx-distribution.txt
index 4f98323..0e64400 100644
--- a/lib/lp/registry/stories/webservice/xx-distribution.txt
+++ b/lib/lp/registry/stories/webservice/xx-distribution.txt
@@ -40,6 +40,7 @@ And for every distribution we publish most of its attributes.
driver_link: None
homepage_content: None
icon_link: 'http://.../ubuntu/icon'
+ information_type: 'Public'
logo_link: 'http://.../ubuntu/logo'
main_archive_link: 'http://.../ubuntu/+archive/primary'
members_link: 'http://.../~ubuntu-team'
@@ -54,6 +55,7 @@ And for every distribution we publish most of its attributes.
official_codehosting: False
official_packages: True
owner_link: 'http://.../~ubuntu-team'
+ private: False
redirect_default_traversal: False
redirect_release_uploads: False
registrant_link: 'http://.../~registry'
diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py
index 427d95e..49f55df 100644
--- a/lib/lp/registry/tests/test_distribution.py
+++ b/lib/lp/registry/tests/test_distribution.py
@@ -21,10 +21,15 @@ from zope.security.interfaces import Unauthorized
from zope.security.proxy import removeSecurityProxy
from lp.app.enums import (
+ FREE_INFORMATION_TYPES,
InformationType,
+ PILLAR_INFORMATION_TYPES,
ServiceUsage,
)
-from lp.app.errors import NotFoundError
+from lp.app.errors import (
+ NotFoundError,
+ ServiceUsageForbidden,
+ )
from lp.app.interfaces.launchpad import ILaunchpadCelebrities
from lp.oci.tests.helpers import OCIConfigHelperMixin
from lp.registry.enums import (
@@ -33,12 +38,19 @@ from lp.registry.enums import (
DistributionDefaultTraversalPolicy,
EXCLUSIVE_TEAM_POLICY,
INCLUSIVE_TEAM_POLICY,
+ TeamMembershipPolicy,
)
from lp.registry.errors import (
+ CannotChangeInformationType,
+ CommercialSubscribersOnly,
InclusiveTeamLinkageError,
NoSuchDistroSeries,
+ ProprietaryPillar,
+ )
+from lp.registry.interfaces.accesspolicy import (
+ IAccessPolicyGrantSource,
+ IAccessPolicySource,
)
-from lp.registry.interfaces.accesspolicy import IAccessPolicySource
from lp.registry.interfaces.distribution import (
IDistribution,
IDistributionSet,
@@ -46,7 +58,9 @@ from lp.registry.interfaces.distribution import (
from lp.registry.interfaces.oopsreferences import IHasOOPSReferences
from lp.registry.interfaces.person import IPersonSet
from lp.registry.interfaces.series import SeriesStatus
+from lp.registry.model.distribution import Distribution
from lp.registry.tests.test_distroseries import CurrentSourceReleasesMixin
+from lp.services.librarianserver.testing.fake import FakeLibrarian
from lp.services.propertycache import get_property_cache
from lp.services.webapp import canonical_url
from lp.services.webapp.interfaces import OAuthPermission
@@ -73,6 +87,9 @@ from lp.testing.views import create_initialized_view
from lp.translations.enums import TranslationPermission
+PRIVATE_DISTRIBUTION_TYPES = [InformationType.PROPRIETARY]
+
+
class TestDistribution(TestCaseWithFactory):
layer = DatabaseFunctionalLayer
@@ -370,6 +387,362 @@ class TestDistribution(TestCaseWithFactory):
DistributionDefaultTraversalPolicy.SERIES)
distro.redirect_default_traversal = True
+ def test_creation_grants_maintainer_access(self):
+ # Creating a new distribution creates an access grant for the
+ # maintainer for all default policies.
+ distribution = self.factory.makeDistribution()
+ policies = getUtility(IAccessPolicySource).findByPillar(
+ (distribution,))
+ grants = getUtility(IAccessPolicyGrantSource).findByPolicy(policies)
+ expected_grantess = {distribution.owner}
+ grantees = {grant.grantee for grant in grants}
+ self.assertEqual(expected_grantess, grantees)
+
+ def test_change_info_type_proprietary_check_artifacts(self):
+ # Cannot change distribution information_type if any artifacts are
+ # public.
+ # XXX cjwatson 2022-02-11: Make this use
+ # artifact.transitionToInformationType once sharing policies are in
+ # place.
+ distribution = self.factory.makeDistribution()
+ self.useContext(person_logged_in(distribution.owner))
+ spec = self.factory.makeSpecification(distribution=distribution)
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ with ExpectedException(
+ CannotChangeInformationType,
+ "Some blueprints are public."):
+ distribution.information_type = info_type
+ removeSecurityProxy(spec).information_type = (
+ InformationType.PROPRIETARY)
+ dsp = self.factory.makeDistributionSourcePackage(
+ distribution=distribution)
+ bug = self.factory.makeBug(target=dsp)
+ for bug_info_type in FREE_INFORMATION_TYPES:
+ removeSecurityProxy(bug).information_type = bug_info_type
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ with ExpectedException(
+ CannotChangeInformationType,
+ "Some bugs are neither proprietary nor embargoed."):
+ distribution.information_type = info_type
+ removeSecurityProxy(bug).information_type = InformationType.PROPRIETARY
+ distroseries = self.factory.makeDistroSeries(distribution=distribution)
+ sp = self.factory.makeSourcePackage(distroseries=distroseries)
+ branch = self.factory.makeBranch(sourcepackage=sp)
+ for branch_info_type in FREE_INFORMATION_TYPES:
+ removeSecurityProxy(branch).information_type = branch_info_type
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ with ExpectedException(
+ CannotChangeInformationType,
+ "Some branches are neither proprietary nor "
+ "embargoed."):
+ distribution.information_type = info_type
+ removeSecurityProxy(branch).information_type = (
+ InformationType.PROPRIETARY)
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ distribution.information_type = info_type
+
+ def test_change_info_type_proprietary_check_translations(self):
+ distribution = self.factory.makeDistribution()
+ with person_logged_in(distribution.owner):
+ for usage in ServiceUsage:
+ distribution.information_type = InformationType.PUBLIC
+ distribution.translations_usage = usage.value
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ if (distribution.translations_usage ==
+ ServiceUsage.LAUNCHPAD):
+ with ExpectedException(
+ CannotChangeInformationType,
+ "Translations are enabled."):
+ distribution.information_type = info_type
+ else:
+ distribution.information_type = info_type
+
+ def test_cacheAccessPolicies(self):
+ # Distribution.access_policies is a list caching AccessPolicy.ids
+ # for which an AccessPolicyGrant or AccessArtifactGrant gives a
+ # principal LimitedView on the Distribution.
+ aps = getUtility(IAccessPolicySource)
+
+ # Public distributions don't need a cache.
+ distribution = self.factory.makeDistribution()
+ naked_distribution = removeSecurityProxy(distribution)
+ self.assertContentEqual(
+ [InformationType.USERDATA, InformationType.PRIVATESECURITY],
+ [p.type for p in aps.findByPillar([distribution])])
+ self.assertIsNone(naked_distribution.access_policies)
+
+ # A private distribution normally just allows the Proprietary
+ # policy, even if there is still another policy like Private
+ # Security.
+ naked_distribution.information_type = InformationType.PROPRIETARY
+ [prop_policy] = aps.find([(distribution, InformationType.PROPRIETARY)])
+ self.assertEqual([prop_policy.id], naked_distribution.access_policies)
+
+ # If we switch it back to public, the cache is no longer
+ # required.
+ naked_distribution.information_type = InformationType.PUBLIC
+ self.assertIsNone(naked_distribution.access_policies)
+
+ def test_checkInformationType_bug_supervisor(self):
+ # Bug supervisors of proprietary distributions must not have
+ # inclusive membership policies.
+ team = self.factory.makeTeam()
+ distribution = self.factory.makeDistribution(bug_supervisor=team)
+ for policy in (token.value for token in TeamMembershipPolicy):
+ with person_logged_in(team.teamowner):
+ team.membership_policy = policy
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ with person_logged_in(distribution.owner):
+ errors = list(distribution.checkInformationType(info_type))
+ if policy in EXCLUSIVE_TEAM_POLICY:
+ self.assertEqual([], errors)
+ else:
+ with ExpectedException(
+ CannotChangeInformationType,
+ "Bug supervisor has inclusive membership."):
+ raise errors[0]
+
+ def test_checkInformationType_questions(self):
+ # Proprietary distributions must not have questions.
+ distribution = self.factory.makeDistribution()
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ with person_logged_in(distribution.owner):
+ self.assertEqual([],
+ list(distribution.checkInformationType(info_type)))
+ self.factory.makeQuestion(target=distribution)
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ with person_logged_in(distribution.owner):
+ error, = list(distribution.checkInformationType(info_type))
+ with ExpectedException(
+ CannotChangeInformationType,
+ "This distribution has questions."):
+ raise error
+
+ def test_checkInformationType_translations(self):
+ # Proprietary distributions must not have translations.
+ distroseries = self.factory.makeDistroSeries()
+ distribution = distroseries.distribution
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ with person_logged_in(distribution.owner):
+ self.assertEqual(
+ [], list(distribution.checkInformationType(info_type)))
+ self.factory.makePOTemplate(distroseries=distroseries)
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ with person_logged_in(distribution.owner):
+ error, = list(distribution.checkInformationType(info_type))
+ with ExpectedException(
+ CannotChangeInformationType,
+ "This distribution has translations."):
+ raise error
+
+ def test_checkInformationType_queued_translations(self):
+ # Proprietary distributions must not have queued translations.
+ self.useFixture(FakeLibrarian())
+ distroseries = self.factory.makeDistroSeries()
+ distribution = distroseries.distribution
+ entry = self.factory.makeTranslationImportQueueEntry(
+ distroseries=distroseries)
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ with person_logged_in(distribution.owner):
+ error, = list(distribution.checkInformationType(info_type))
+ with ExpectedException(
+ CannotChangeInformationType,
+ "This distribution has queued translations."):
+ raise error
+ Store.of(entry).remove(entry)
+ with person_logged_in(distribution.owner):
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ self.assertContentEqual(
+ [], distribution.checkInformationType(info_type))
+
+ def test_checkInformationType_series_only_bugs(self):
+ # A distribution with bugtasks that are only targeted to a series
+ # cannot change information type.
+ series = self.factory.makeDistroSeries()
+ bug = self.factory.makeBug(target=series.distribution)
+ with person_logged_in(series.owner):
+ bug.addTask(series.owner, series)
+ bug.default_bugtask.delete()
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ error, = list(series.distribution.checkInformationType(
+ info_type))
+ with ExpectedException(
+ CannotChangeInformationType,
+ "Some bugs are neither proprietary nor embargoed."):
+ raise error
+
+ def test_private_forbids_translations(self):
+ owner = self.factory.makePerson()
+ distribution = self.factory.makeDistribution(owner=owner)
+ self.useContext(person_logged_in(owner))
+ for info_type in PRIVATE_DISTRIBUTION_TYPES:
+ distribution.information_type = info_type
+ with ExpectedException(
+ ProprietaryPillar,
+ "Translations are not supported for proprietary "
+ "distributions."):
+ distribution.translations_usage = ServiceUsage.LAUNCHPAD
+ for usage in ServiceUsage.items:
+ if usage == ServiceUsage.LAUNCHPAD:
+ continue
+ distribution.translations_usage = usage
+
+ def createDistribution(self, information_type=None):
+ # Convenience method for testing IDistributionSet.new rather than
+ # self.factory.makeDistribution.
+ owner = self.factory.makePerson()
+ members = self.factory.makeTeam(owner=owner)
+ kwargs = {}
+ if information_type is not None:
+ kwargs['information_type'] = information_type
+ with person_logged_in(owner):
+ return getUtility(IDistributionSet).new(
+ name=self.factory.getUniqueUnicode("distro"),
+ display_name="Fnord", title="Fnord",
+ description="test 1", summary="test 2",
+ domainname="distro.example.org",
+ members=members, owner=owner, registrant=owner, **kwargs)
+
+ def test_information_type(self):
+ # Distribution is created with specified information_type.
+ distribution = self.createDistribution(
+ information_type=InformationType.PROPRIETARY)
+ self.assertEqual(
+ InformationType.PROPRIETARY, distribution.information_type)
+ # The owner can set information_type.
+ with person_logged_in(removeSecurityProxy(distribution).owner):
+ distribution.information_type = InformationType.PUBLIC
+ self.assertEqual(InformationType.PUBLIC, distribution.information_type)
+ # The database persists the value of information_type.
+ store = Store.of(distribution)
+ store.flush()
+ store.reset()
+ distribution = store.get(Distribution, distribution.id)
+ self.assertEqual(InformationType.PUBLIC, distribution.information_type)
+ self.assertFalse(distribution.private)
+
+ def test_switching_to_public_does_not_create_policy(self):
+ # Creating a Proprietary distribution and switching it to Public
+ # does not create a PUBLIC AccessPolicy.
+ distribution = self.createDistribution(
+ information_type=InformationType.PROPRIETARY)
+ aps = getUtility(IAccessPolicySource).findByPillar([distribution])
+ self.assertContentEqual(
+ [InformationType.PROPRIETARY],
+ [ap.type for ap in aps])
+ removeSecurityProxy(distribution).information_type = (
+ InformationType.PUBLIC)
+ aps = getUtility(IAccessPolicySource).findByPillar([distribution])
+ self.assertContentEqual(
+ [InformationType.PROPRIETARY],
+ [ap.type for ap in aps])
+
+ def test_information_type_default(self):
+ # The default information_type is PUBLIC.
+ distribution = self.createDistribution()
+ self.assertEqual(InformationType.PUBLIC, distribution.information_type)
+ self.assertFalse(distribution.private)
+
+ invalid_information_types = [
+ info_type for info_type in InformationType.items
+ if info_type not in PILLAR_INFORMATION_TYPES]
+
+ def test_information_type_init_invalid_values(self):
+ # Cannot create Distribution.information_type with invalid values.
+ for info_type in self.invalid_information_types:
+ with ExpectedException(
+ CannotChangeInformationType,
+ "Not supported for distributions."):
+ self.createDistribution(information_type=info_type)
+
+ def test_information_type_set_invalid_values(self):
+ # Cannot set Distribution.information_type to invalid values.
+ distribution = self.factory.makeDistribution()
+ for info_type in self.invalid_information_types:
+ with ExpectedException(
+ CannotChangeInformationType,
+ "Not supported for distributions."):
+ with person_logged_in(distribution.owner):
+ distribution.information_type = info_type
+
+ def test_set_proprietary_gets_commercial_subscription(self):
+ # Changing a Distribution to Proprietary will auto-generate a
+ # complimentary subscription just as choosing a proprietary
+ # information type at creation time.
+ owner = self.factory.makePerson()
+ distribution = self.factory.makeDistribution(owner=owner)
+ self.useContext(person_logged_in(owner))
+ self.assertIsNone(distribution.commercial_subscription)
+
+ distribution.information_type = InformationType.PROPRIETARY
+ self.assertEqual(
+ InformationType.PROPRIETARY, distribution.information_type)
+ self.assertIsNotNone(distribution.commercial_subscription)
+
+ def test_set_proprietary_fails_expired_commercial_subscription(self):
+ # Cannot set information type to proprietary with an expired
+ # complimentary subscription.
+ owner = self.factory.makePerson()
+ distribution = self.factory.makeDistribution(
+ information_type=InformationType.PROPRIETARY, owner=owner)
+ self.useContext(person_logged_in(owner))
+
+ # The Distribution now has a complimentary commercial subscription.
+ new_expires_date = (
+ datetime.datetime.now(pytz.UTC) - datetime.timedelta(1))
+ naked_subscription = removeSecurityProxy(
+ distribution.commercial_subscription)
+ naked_subscription.date_expires = new_expires_date
+
+ # We can make the distribution PUBLIC.
+ distribution.information_type = InformationType.PUBLIC
+ self.assertEqual(InformationType.PUBLIC, distribution.information_type)
+
+ # However we can't change it back to Proprietary because our
+ # commercial subscription has expired.
+ with ExpectedException(
+ CommercialSubscribersOnly,
+ "A valid commercial subscription is required for private"
+ " distributions."):
+ distribution.information_type = InformationType.PROPRIETARY
+
+ def test_no_answers_for_proprietary(self):
+ # Enabling Answers is forbidden while information_type is proprietary.
+ distribution = self.factory.makeDistribution(
+ information_type=InformationType.PROPRIETARY)
+ with person_logged_in(removeSecurityProxy(distribution).owner):
+ self.assertEqual(ServiceUsage.UNKNOWN, distribution.answers_usage)
+ for usage in ServiceUsage.items:
+ if usage == ServiceUsage.LAUNCHPAD:
+ with ExpectedException(
+ ServiceUsageForbidden,
+ "Answers not allowed for non-public "
+ "distributions."):
+ distribution.answers_usage = ServiceUsage.LAUNCHPAD
+ else:
+ # All other values are permitted.
+ distribution.answers_usage = usage
+
+ def test_answers_for_public(self):
+ # Enabling answers is permitted while information_type is PUBLIC.
+ distribution = self.factory.makeDistribution(
+ information_type=InformationType.PUBLIC)
+ self.assertEqual(ServiceUsage.UNKNOWN, distribution.answers_usage)
+ with person_logged_in(distribution.owner):
+ for usage in ServiceUsage.items:
+ # All values are permitted.
+ distribution.answers_usage = usage
+
+ def test_no_proprietary_if_answers(self):
+ # Information type cannot be set to proprietary while Answers are
+ # enabled.
+ distribution = self.factory.makeDistribution()
+ with person_logged_in(distribution.owner):
+ distribution.answers_usage = ServiceUsage.LAUNCHPAD
+ with ExpectedException(
+ CannotChangeInformationType, "Answers is enabled."):
+ distribution.information_type = InformationType.PROPRIETARY
+
class TestDistributionCurrentSourceReleases(
CurrentSourceReleasesMixin, TestCase):
diff --git a/lib/lp/registry/tests/test_person.py b/lib/lp/registry/tests/test_person.py
index 10d8456..c601415 100644
--- a/lib/lp/registry/tests/test_person.py
+++ b/lib/lp/registry/tests/test_person.py
@@ -831,14 +831,24 @@ class TestPerson(TestCaseWithFactory):
self.assertTrue(owner.isAnyPillarOwner())
self.assertFalse(person.isAnyPillarOwner())
- def test_has_current_commercial_subscription(self):
- # IPerson.hasCurrentCommercialSubscription() checks for one.
+ def test_has_current_commercial_subscription_product(self):
+ # IPerson.hasCurrentCommercialSubscription() checks for one on a
+ # product.
team = self.factory.makeTeam(
membership_policy=TeamMembershipPolicy.MODERATED)
product = self.factory.makeProduct(owner=team)
self.factory.makeCommercialSubscription(product)
self.assertTrue(team.teamowner.hasCurrentCommercialSubscription())
+ def test_has_current_commercial_subscription_distribution(self):
+ # IPerson.hasCurrentCommercialSubscription() checks for one on a
+ # distribution.
+ team = self.factory.makeTeam(
+ membership_policy=TeamMembershipPolicy.MODERATED)
+ distro = self.factory.makeDistribution(owner=team)
+ self.factory.makeCommercialSubscription(distro)
+ self.assertTrue(team.teamowner.hasCurrentCommercialSubscription())
+
def test_does_not_have_current_commercial_subscription(self):
# IPerson.hasCurrentCommercialSubscription() is false if it has
# expired.
@@ -846,6 +856,8 @@ class TestPerson(TestCaseWithFactory):
membership_policy=TeamMembershipPolicy.MODERATED)
product = self.factory.makeProduct(owner=team)
self.factory.makeCommercialSubscription(product, expired=True)
+ distro = self.factory.makeDistribution(owner=team)
+ self.factory.makeCommercialSubscription(distro, expired=True)
self.assertFalse(team.teamowner.hasCurrentCommercialSubscription())
def test_does_not_have_commercial_subscription(self):
diff --git a/lib/lp/registry/tests/test_pillaraffiliation.py b/lib/lp/registry/tests/test_pillaraffiliation.py
index 0098e28..e757d0e 100644
--- a/lib/lp/registry/tests/test_pillaraffiliation.py
+++ b/lib/lp/registry/tests/test_pillaraffiliation.py
@@ -3,11 +3,11 @@
"""Tests for adapters."""
-from storm.store import Store
from testtools.matchers import Equals
from zope.component import getUtility
from lp.registry.model.pillaraffiliation import IHasAffiliation
+from lp.services.database.sqlbase import flush_database_caches
from lp.services.worlddata.interfaces.language import ILanguageSet
from lp.testing import (
person_logged_in,
@@ -146,21 +146,20 @@ class TestPillarAffiliation(TestCaseWithFactory):
# - Product, Person
person = self.factory.makePerson()
product = self.factory.makeProduct(owner=person, name='pting')
- Store.of(product).invalidate()
+ flush_database_caches()
with StormStatementRecorder() as recorder:
IHasAffiliation(product).getAffiliationBadges([person])
- self.assertThat(recorder, HasQueryCount(Equals(4)))
+ self.assertThat(recorder, HasQueryCount(Equals(2)))
def test_distro_affiliation_query_count(self):
# Only 2 business queries are expected, selects from:
# - Distribution, Person
- # plus an additional query to create a PublisherConfig record.
person = self.factory.makePerson()
distro = self.factory.makeDistribution(owner=person, name='pting')
- Store.of(distro).invalidate()
+ flush_database_caches()
with StormStatementRecorder() as recorder:
IHasAffiliation(distro).getAffiliationBadges([person])
- self.assertThat(recorder, HasQueryCount(Equals(3)))
+ self.assertThat(recorder, HasQueryCount(Equals(2)))
class _TestBugTaskorBranchMixin:
diff --git a/lib/lp/registry/tests/test_product.py b/lib/lp/registry/tests/test_product.py
index c037549..61f147a 100644
--- a/lib/lp/registry/tests/test_product.py
+++ b/lib/lp/registry/tests/test_product.py
@@ -68,7 +68,7 @@ from lp.registry.errors import (
CannotChangeInformationType,
CommercialSubscribersOnly,
InclusiveTeamLinkageError,
- ProprietaryProduct,
+ ProprietaryPillar,
)
from lp.registry.interfaces.accesspolicy import (
IAccessPolicyGrantSource,
@@ -635,7 +635,7 @@ class TestProduct(TestCaseWithFactory):
for info_type in PRIVATE_PROJECT_TYPES:
product.information_type = info_type
with ExpectedException(
- ProprietaryProduct,
+ ProprietaryPillar,
"Translations are not supported for proprietary products."):
product.translations_usage = ServiceUsage.LAUNCHPAD
for usage in ServiceUsage.items:
@@ -1761,7 +1761,7 @@ class BaseSharingPolicyTests:
InformationType.PUBLIC in self.allowed_types[policy])
for policy in policies_permitting_public:
with ExpectedException(
- ProprietaryProduct, "The project is Proprietary."):
+ ProprietaryPillar, "The project is Proprietary."):
self.setSharingPolicy(policy, owner)
diff --git a/lib/lp/registry/tests/test_productrelease.py b/lib/lp/registry/tests/test_productrelease.py
index 39c8222..85ecbca 100644
--- a/lib/lp/registry/tests/test_productrelease.py
+++ b/lib/lp/registry/tests/test_productrelease.py
@@ -8,7 +8,7 @@ from zope.component import getUtility
from lp.app.enums import InformationType
from lp.registry.errors import (
InvalidFilename,
- ProprietaryProduct,
+ ProprietaryPillar,
)
from lp.registry.interfaces.productrelease import (
IProductReleaseSet,
@@ -101,5 +101,5 @@ class ProductReleaseFileTestcase(TestCaseWithFactory):
release = self.factory.makeProductRelease(product=product)
self.assertFalse(release.can_have_release_files)
self.assertRaises(
- ProprietaryProduct, release.addReleaseFile,
+ ProprietaryPillar, release.addReleaseFile,
'README', b'test', 'text/plain', owner)
diff --git a/lib/lp/registry/tests/test_productseries.py b/lib/lp/registry/tests/test_productseries.py
index da6f828..e80452e 100644
--- a/lib/lp/registry/tests/test_productseries.py
+++ b/lib/lp/registry/tests/test_productseries.py
@@ -20,7 +20,7 @@ from lp.app.interfaces.services import IService
from lp.registry.enums import SharingPermission
from lp.registry.errors import (
CannotPackageProprietaryProduct,
- ProprietaryProduct,
+ ProprietaryPillar,
)
from lp.registry.interfaces.distribution import IDistributionSet
from lp.registry.interfaces.distroseries import IDistroSeriesSet
@@ -73,7 +73,7 @@ class TestProductSeries(TestCaseWithFactory):
for mode in TranslationsBranchImportMode.items:
if mode == TranslationsBranchImportMode.NO_IMPORT:
continue
- with ExpectedException(ProprietaryProduct,
+ with ExpectedException(ProprietaryPillar,
'Translations are disabled for proprietary'
' projects.'):
series.translations_autoimport_mode = mode
diff --git a/lib/lp/security.py b/lib/lp/security.py
index 22bc140..95d7a45 100644
--- a/lib/lp/security.py
+++ b/lib/lp/security.py
@@ -1282,7 +1282,11 @@ class EditDistributionByDistroOwnersOrAdmins(AuthorizationBase):
usedfor = IDistribution
def checkAuthenticated(self, user):
- return user.isOwner(self.obj) or user.in_admin
+ # Commercial admins may help setup commercial distributions.
+ return (
+ user.isOwner(self.obj)
+ or is_commercial_case(self.obj, user)
+ or user.in_admin)
class ModerateDistributionByDriversOrOwnersOrAdmins(AuthorizationBase):
@@ -3266,6 +3270,10 @@ class ViewPublisherConfig(AdminByAdminsTeam):
usedfor = IPublisherConfig
+class ViewSourcePackage(AnonymousAuthorization):
+ usedfor = ISourcePackage
+
+
class EditSourcePackage(EditDistributionSourcePackage):
usedfor = ISourcePackage
diff --git a/lib/lp/soyuz/tests/test_build_set.py b/lib/lp/soyuz/tests/test_build_set.py
index 6dcf9fd..b2a795c 100644
--- a/lib/lp/soyuz/tests/test_build_set.py
+++ b/lib/lp/soyuz/tests/test_build_set.py
@@ -29,7 +29,10 @@ from lp.testing import (
person_logged_in,
TestCaseWithFactory,
)
-from lp.testing.dbuser import lp_dbuser
+from lp.testing.dbuser import (
+ lp_dbuser,
+ switch_dbuser,
+ )
from lp.testing.layers import (
LaunchpadFunctionalLayer,
ZopelessDatabaseLayer,
@@ -348,6 +351,13 @@ class BuildRecordCreationTests(TestNativePublishingBase):
def setUp(self):
super().setUp()
+
+ # TestNativePublishingBase switches to the archive publisher's
+ # database user, but the publisher doesn't create build records so
+ # we aren't really interested in its database permissions here.
+ # Just use the webapp's database user instead.
+ switch_dbuser("launchpad")
+
self.distro = self.factory.makeDistribution()
self.avr = self.factory.makeProcessor(
name="avr2001", supports_virtualized=True)
diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py
index b098cba..d4b9f20 100644
--- a/lib/lp/testing/factory.py
+++ b/lib/lp/testing/factory.py
@@ -2709,7 +2709,7 @@ class BareLaunchpadObjectFactory(ObjectFactory):
publish_root_dir=None, publish_base_url=None,
publish_copy_base_url=None, no_pubconf=False,
icon=None, summary=None, vcs=None,
- oci_project_admin=None):
+ oci_project_admin=None, information_type=None):
"""Make a new distribution."""
if name is None:
name = self.getUniqueString(prefix="distribution")
@@ -2729,7 +2729,8 @@ class BareLaunchpadObjectFactory(ObjectFactory):
members = self.makeTeam(owner)
distro = getUtility(IDistributionSet).new(
name, displayname, title, description, summary, domainname,
- members, owner, registrant, icon=icon, vcs=vcs)
+ members, owner, registrant, icon=icon, vcs=vcs,
+ information_type=information_type)
naked_distro = removeSecurityProxy(distro)
if aliases is not None:
naked_distro.setAliases(aliases)
Follow ups