[patchew-devel] [PATCH v4 2/3] subproject support

Fam Zheng posted 3 patches 6 years, 2 months ago
[patchew-devel] [PATCH v4 2/3] subproject support
Posted by Fam Zheng 6 years, 2 months ago
This extends Project model to allow a simple (two-level) "subproject"
hierarchy.

A parent_project field is added. It can be NULL for the usual top-level
projects, or can point to an existing top-level project, to make a
"subproject".

The project list page only shows top-level project. But the series list
page shows series in both the top-level project or in a subprojct.

The link to a subproject's information page is added in the top-level
one's.

Signed-off-by: Fam Zheng <famz@redhat.com>
---
 api/migrations/0022_project_parent_project.py |  21 +++++++++++++++++++++
 api/models.py                                 |  17 +++++++++++++++--
 api/rest.py                                   |   2 +-
 api/search.py                                 |   2 +-
 tests/data/0019-libvirt-python.mbox.gz        | Bin 0 -> 1816 bytes
 tests/data/0020-libvirt.mbox.gz               | Bin 0 -> 4034 bytes
 tests/test_import.py                          |  26 ++++++++++++++++++++++++++
 www/templates/project-detail.html             |   9 +++++++++
 www/views.py                                  |   4 ++--
 9 files changed, 75 insertions(+), 6 deletions(-)
 create mode 100644 api/migrations/0022_project_parent_project.py
 create mode 100644 tests/data/0019-libvirt-python.mbox.gz
 create mode 100644 tests/data/0020-libvirt.mbox.gz

diff --git a/api/migrations/0022_project_parent_project.py b/api/migrations/0022_project_parent_project.py
new file mode 100644
index 0000000..ba91400
--- /dev/null
+++ b/api/migrations/0022_project_parent_project.py
@@ -0,0 +1,21 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.10 on 2018-02-14 06:19
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('api', '0021_fix_recipients_utf8'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='project',
+            name='parent_project',
+            field=models.ForeignKey(blank=True, help_text='Parent project which this\n                                       project belongs to', null=True, on_delete=django.db.models.deletion.CASCADE, to='api.Project'),
+        ),
+    ]
diff --git a/api/models.py b/api/models.py
index 2291941..0c8688a 100644
--- a/api/models.py
+++ b/api/models.py
@@ -69,6 +69,13 @@ class Project(models.Model):
     display_order = models.IntegerField(default=0,
                                         help_text="""Order number of the project
                                         to display, higher number first""")
+    parent_project = models.ForeignKey('Project', on_delete=models.CASCADE,
+                                       blank=True, null=True,
+                                       help_text="""Parent project which this
+                                       project belongs to. The parent must be a
+                                       top project which has
+                                       parent_project=NULL""")
+
     def __str__(self):
         return self.name
 
@@ -151,6 +158,9 @@ class Project(models.Model):
             return True
         return False
 
+    def get_subprojects(self):
+        return Project.objects.filter(parent_project=self)
+
 class ProjectProperty(models.Model):
     project = models.ForeignKey('Project', on_delete=models.CASCADE)
     name = models.CharField(max_length=1024, db_index=True)
@@ -175,9 +185,12 @@ class MessageManager(models.Manager):
         q = super(MessageManager, self).get_queryset()\
                 .filter(is_series_head=True).prefetch_related('properties', 'project')
         if isinstance(project, str):
-            q = q.filter(project__name=project)
+            po = Project.objects.get(name=project)
         elif isinstance(project, int):
-            q = q.filter(project__id=project)
+            po = Project.objects.get(id=project)
+        else:
+            return q
+        q = q.filter(project=po) | q.filter(project__parent_project=po)
         return q
 
     def find_series(self, message_id, project_name=None):
diff --git a/api/rest.py b/api/rest.py
index 221a1c1..be774a9 100644
--- a/api/rest.py
+++ b/api/rest.py
@@ -89,7 +89,7 @@ class ProjectSerializer(serializers.HyperlinkedModelSerializer):
     class Meta:
         model = Project
         fields = ('resource_uri', 'name', 'mailing_list', 'prefix_tags', 'url', 'git', \
-                  'description', 'display_order', 'logo')
+                  'description', 'display_order', 'logo', 'parent_project')
 
 class ProjectsViewSet(viewsets.ModelViewSet):
     queryset = Project.objects.all().order_by('id')
diff --git a/api/search.py b/api/search.py
index 6fae279..ba3703c 100644
--- a/api/search.py
+++ b/api/search.py
@@ -192,7 +192,7 @@ Search text keyword in the email message. Example:
         elif term.startswith("project:"):
             cond = term[term.find(":") + 1:]
             self._projects.add(cond)
-            q = Q(project__name=cond)
+            q = Q(project__name=cond) | Q(project__parent_project__name=cond)
         else:
             # Keyword in subject is the default
             q = as_keywords(term)
diff --git a/tests/data/0019-libvirt-python.mbox.gz b/tests/data/0019-libvirt-python.mbox.gz
new file mode 100644
index 0000000000000000000000000000000000000000..12a0fb9371bef6518ea9c0b375a6977998484641
GIT binary patch
literal 1816
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
G5dZ*bB%nP2

literal 0
HcmV?d00001

diff --git a/tests/data/0020-libvirt.mbox.gz b/tests/data/0020-libvirt.mbox.gz
new file mode 100644
index 0000000000000000000000000000000000000000..ecd6858637e9b81395a98eee3a82d864ae085a94
GIT binary patch
literal 4034
zcmV;z4?XZ7iwFqE<bzrO128Z$FfD9pVs>eAbS`aTZ+HN;TKRL@Sn~hb`YU=;wE<!z
zOX3pv*kHgh-}1o#&SWQ*rG$7GO@yS?;bS)Qzkj=*1V|uZ$6G@s2Hw%H`_uOm7oM|(
zN8P@cp3PeG<DUVO3}-1BHe5S4T(Bu9Drl`*wWTVc%8Dj*nPomOkIlrf(*kqpI$prM
zkFFjV3-%~E-n}qn2E#QoaWuGSfvfvI6#sM<4v}ub%vtKD4bB{p$U+Zj6lTmf?`=I`
zKAzH)l!_`TT18Sd(0y>3w={!*&A?~&jCmz6ER)%RXu7BA)yKf>2WB}g6Xz(|sbx+d
zOi#2-KM<!*XdBG`xH<J07P$6w`J_mRL7^Q63#4uudSE)XIAnfk1%3;bPZCLi+*{GY
z1K*vW{vG0hzYJW74tj+Y2zL;Awm8s(MGH>$FFso<rv*eC(!xV=>-*O<yWdJMeclE_
zICwOJ1&pr8gAtfBP-==+RjX>FuGOo}h9Wf@O|@33G~{ZvhAKkKDwA1}b&*MdEVn93
zOR0k>YqAW*!Dw9C=9430IG&s0)<h#%KFL)HKX7(j&zaE|$z43DDM1ujs!56>Db;T!
z;h-4$rhO0NtI>lZslWv=On1RNX!knZOR?P>skOQ|x@<!cKOEtIn3{oKqRlr>Y=K}f
zFEHn1Dh&1gpOE>eM_j^DN+UaP{9tZAm7>FXYq+#tTT#_cQ$vH=g+}Ah)ZkE-37Wfc
zX9pU?u^dEU7oB)QR(}E>RrwaNpAC4P5S{am+^uz+_1YdOs{5oMgY{)aa>MD;^o@vB
z_O#1t@zpD1p_)>q5(||JRfb$7)_F~?snvG1-q|BoWuI7k?e=BG2nQR(RbwGukCcY4
zD``j3TT(XR0EK&0I@;%sX~-(MCd?f#6+J%EpQUa~VL%Kno)V<bGU2FjI8=1wnk*-P
zc+r|TJ5^29nsxcS*U1VIT1<Nn)Y}e+kVL@&KWsqG0BQrW%wyae?7ymL=bZV5aIgbp
z61P0jWNp7_i|<k*ITyDFzSc23D-B7LrH1@MfTQ@<6NjVrD#(56TvMQ{Xw|<v*0m8J
zwV~Cj@>^l5?}f={=od+xLi@YzJlZ@IRPdjq%CUYg9M52`gFXj6vW{(5&nsQI)2#H)
zF(=g&r7bIs`cE^h)H%}>_;_aPWMnqm!Lne&%`M4!%rPpj(u`T&5icFA$e-^*?@q2`
z_WgN)iC4K*tFL(dbUhX|vVc@cm02wA$TWAJ#0A=bCRvW5TX9XqWolG0lF8EA&Hg0G
z<$6l?D<=qj=sHW&c3{XXruz&u^w7%1pk9jWjkVmwy{sy#Qm@FhrcxKHwWeIfdNnIZ
z#ZopSDTND<YPNrw@EeK+Ct1Rs2~_B2<0y2|8Cn^}T8T^MC=^~Z-`DS%*hhR$qVlL#
zuPb7;-qci45%0}Fq}oqpWQk%n7hlBd{&jEc>BjdK^aHju|6~+eISeD_VNniPvyXnK
z0l9wU^PwXoF|mSKF@{k%SmF5N#(7}NDeh90GO=ALfgW)FMH#tL*I1Q>1Eg>2R(#^n
z34`dcjqPrq15F8y_hQia(GKrJKLAh~NC&CcbB?K}jzOtii(#b6UQ1}S`&X?DpGbSb
z7x9zFo=I&j1c}OP-jHCijm-%l_d$6z-XFl@!VDNK#(5O9dovoIUzMTVxk4r|v@dy=
zGb@weSkznkbF^@2T8M~vA0H)FDVx-{tLg1LlON?8sVJ9}!L)ss8NtsS3tOk=Rx;^h
zc|p)0v<k`8QV<Thw}|{rtFV=V3UJxGy4@WX4lahb*R4X9Q5Rs?8H`(n0*W|fvr9b?
zN3OmUN0@iftwdF-C<;Jv*!kR@^lv7pkKe8)*S&6;v^N>FM<X<v@nkrdT(`%a%id^0
zOG}Vsc{_Ou2m5C8V!#(<O1EunYR{k1;n5SjY)&yll^PnkLIUHxi<^nX7zuH$<v4C`
zfaw^m%#d4#D>Ib-&(!TdLc5ZhcnYN_Q%ej9qj35M8chqnMyCYd0KN_gF?dkF!Gqz3
zu4%VGpUp7YGu?wZ)3K>!$lW<(_87|smdhf#B1SE&6vfZX^RZ=ap`2W{frn{f#;^x%
z0kZjBxFRd1DA;8@r!1+im|+nc`n1sp9b47_u>Xz^3+Ua`y=Qn2f#X=daF9%C$&xf8
zerNmP)HghHif5iIg244#M@Ns3kBOy6%ZT42$E6P6KT1$MLxVb@`M_x<Tj{vqNyz<A
zKSK31+#DG*(e{i5X2X02^k_l)<Y+IIPPlQGg^xS3UNXzgT)L0j1#XJ>k6w=yQ8n$k
z^FKnB0E)a~V=#&&3mwM}=vHU^>>@`27V1Z?g>}O*7z^F=S#bI;^hMn_O!M7lv8UVq
z9D`ubHk=ujye(*8?J7huJ!P|5cPBm1b39+%%J5k$U+>e^tfZ4pE^kaA$w=PVlRviZ
z3%-cII}3-Lv<1V_#Y6`9eNtDOO|_<CB{Uk0C$b>?hDleq3qc$K;-CPCJ8J&kryH#=
z*H<X1!Qhdh*UnaJ1Y`}31f>cVrVsd!q%$0>8*Sb>gd76jSyEoNXC`?a_@S{7bRR{(
z_xMd4IymS+Wn)y>T39tfXK?3Bf-ok+{EiVbV1RvGi0VnlsxkqL^C(0@1mrH3U?o7*
z80k<j3J?VVq(3t6Nwv;=E>53cEC{z&e=*@p2q!yn_e>B)Q9y<bE1a>|b%NEq7*)m6
z0)7Byxx>v%0N*o?{==3b|1+6+bmJ-+uzCG}ikjS#dWrE%h(hKOp5`xB$0ex{9jI|S
z>BW-k%wXq*i;+(OsxUV#f`5VNkTF+fs!OOn#AsSPER~^2#?7MdVo4BY=6nt!6}q4w
zy$Hzk#U+A?CiR2|S2f+3OH#Eyud+r1oe=B3D&hW$a|D$8HD`VN2qM<(jWYH|{9S1P
zj#2BF^}@|vzIDWc&uD`Fg{7naKyf`W2)y{7xGcO(=F_Xl{PYXc7B^$gz?^7fX$ZpM
z(Fcf5qWTZr6}`=}$YltWP`Ah>0lnVFF$v(qk#HDb#GbJ^Fgskre}zLLk7P!SZKSxy
z=sb__)cZ?UHv)bDrh04^`0+6@+WfgcoJ4iz<TD;O8Q_P`-Ei2u8N+FEwtaEY>x@@(
z<^0`O{m(s`%i2wId$x9NZ!Y?uvbRQaxOpvfdltnv-Ljs`N%3H*Kf{#K&B~+cGx2s<
zAnad~`l{C+;?YF~PEJ5Y*TOS8>+t52CV(<Cr}EbZZ!(#IX>(N`BY!X3*&y1q8vk4D
zjClT^h)^j2ACSsYcgyLpk`pwiITLXRnNAH>o6aO@&Xjpwou@-c&ekY!<V;eysL9yR
zVmrm(HHsFrCWZ4AF5h5$JN<)Tepu$B^CEmm>(}1(U6=F0b;duvZavmhbMu~d8T0$H
zAm(m4Hv@#8FXT!nMYj8@wdK6EQ%pTk(k=-YKz8>BOBX+(AKctsT^+9%Y)l=CF^(Tk
z`E%zu)<V5WLAxe5<Z|N;S|H4^Q?Y{^SNHU#j||AiTT|EiQ|6?{0vr*Q<4EYVIJ$Sw
zaA!iIc;g(3zigPi)cwehXMXWvSt_lti3tuFJ$>ahY>|RTZ#bOX4tm4(xPN<t4fE&r
zRli$~q0lO2MJ6n?S{Vz(x0&R<6F6K^cId{k{)N1YYu<}%O|1pKVUHcheJ;lKQYvk%
zD^2cjMJ2L?RVJZV5-=ouiA5qtNX(44Hs&T49aJpEH2wl+Vc5;tAMtU#nS4FtDf7)>
zehUj9;Y-lFaJ;1+Y?Ir!bj>!c&Qrq1a#q$ao;e&Af-q8k6LpTYTTPKw`tbI6#E&t(
z&b{Y^1kiZlF@08aF^Gs~1|Kc+MArE<yuBMt#+SohyUX)g`6r_PTt@|#;eAALLK{nP
z0&;10cN&dU;38&8PAtSiH)6SiIg<tSx0s+^UInx0jKen)*{JJPysn4WW|Kg8;5pdh
z_;_l2J~wSMcrI>K7MFw=7ceYDxR&x{cssql;^agEZf=UI@vy5@YHvz@YXiIR7%QfX
zAU=vL>|)sKk>9MwHiz^mF8>;`kQL(`7=`N6PK?>Wv~fK;b0d08+~Ys8pf?=Y2wK(3
zO%k-$$a5=RfbG;$<%}HXM*4ewlG|CClg`)cxUidCi{sqS)-i-|Uohi)#B+9`3_r4p
zgWSp(6JY5$EA8j)K>GVvr%U7bfLMS?*c1|I+7TGBDe%RS^6K@-N*j$FE8VNR*ss<W
zj0d59eC|NJ;K%TVO%eWe>6Xi<>ndEZ?MvK`m_7T$nez6FixF$*mtTre>+n7wfD$k6
zR}2-Y4SW9{yYIPpMhzQXrom~db-|D02?11sA3rvX6jN*xk<`E|$o*V-a?$Qz(M|rl
zfB5g>8bxU;yl7VCYEHSfS0jHVb72I;##VDe5Gso+DGK1LXhAjb${)Am>6;0a;|h1b
z0jvesvYP*{Ac`tlRZ%hW{4_*dGwUjW^W}d>OLJF6(R$TRG)iZb?eH0^JP|^7j@ei$
zy`u7Xb6282u-H!#Gu*>JcTI1FB@XE>`cxX0<2)QAdr0+%nU`VH%;C5<tm!h74Mmcw
zO-*5jk?svMH%6Ud<`n6#uGK4K;_*8U@kIl=-$u8lYx&}Xzvx8QF4*V>R+)Dbam#D5
zRS%xo5$|M#O?FA^a$Th2KDu!JDt{xlE_j7?*_}Cy5d*dcjY7)rqjGk)lE$GYO5mlf
zIwDT23AR(LQ@vtYM6VR}boFLcArIj1+yji|EWYg6g$~Wg$F>=Vmopq%&hpFXRRmcJ
zKN=5vzpfy_IAy#`b^uXe{&AWgnb+oi^Z}4LSUNM-THTxhJ;pWXCW~&->F#?o;x3+j
z)6gw~+W&BrXbp>NJ@48L7x?|gPGHS?2J~s+^ZbP015dX{V(+Lo4Wp`7BxzRDF^4vD
zy@T)BaST368dbF#zgp$*+$Z`cG3#gaFhS?Pc%$q4H7;}oX@~UV1z&=G;Di5c2l`XA
znm>&Yz-QE!)01+(UAJdA$1z3%OWpe(y79Asf>_|wg?|_5S7Q<sWU*Q((_5wdZx!v^
z&}{**RC4G7Pq!@Rk<BC+FBsoDWBhjz{IR7+SK^!+eItsiS@vXDc-{k(1p4vO+7M`6
z|AdU-j6VUfthD}PyLjgAr;~sicqHMFNbz7yv{p1J?v^(jjf{=ZpW}B?q+fG$nAhf4
z27Nwf20{j27}2|@2YQ=5QE<-LMe^9}DWF1=YfVqY>>Wy>=i%@kQt^stBlrPoR6-L2
oL6J16A}IoX5fabtfSyPa&-6ved1U>Dr=7z81B@OGj4Lky0I<x)NdN!<

literal 0
HcmV?d00001

diff --git a/tests/test_import.py b/tests/test_import.py
index badae61..23d0d43 100755
--- a/tests/test_import.py
+++ b/tests/test_import.py
@@ -13,6 +13,7 @@ import os
 sys.path.append(os.path.dirname(__file__))
 from patchewtest import PatchewTestCase, main
 import json
+from api.models import Message
 
 class ImportTest(PatchewTestCase):
 
@@ -69,6 +70,31 @@ class ImportTest(PatchewTestCase):
         self.check_cli(["search"],
                        stdout='[edk2] [PATCH 0/3] Revert "ShellPkg: Fix echo to support displaying special characters"')
 
+    def test_import_to_subproject(self):
+        tp = self.add_project("Libvirt", "libvir-list@redhat.com, libvirt-list@redhat.com",
+                              "git://libvirt.org/libvirt.git")
+        tp.prefix_tags = "!python"
+        tp.save()
+        sp = self.add_project("Libvirt-python", "libvir-list@redhat.com, libvirt-list@redhat.com",
+                              "https://github.com/libvirt/libvirt-python")
+        sp.prefix_tags = "python"
+        sp.parent_project = tp
+        sp.save()
+        self.cli_import("0019-libvirt-python.mbox.gz")
+        subj = '[libvirt] [python PATCH] event-test.py: Remove extra ( in --help output'
+        self.check_cli(["search", "project:Libvirt"], stdout=subj)
+        self.check_cli(["search", "project:Libvirt-python"], stdout=subj)
+        sh = Message.objects.series_heads()
+        self.assertEqual(len(sh), 1)
+        s = sh[0]
+        self.assertTrue(s.get_property("git.need-apply", True))
+        self.assertTrue(s.project.name, sp.name)
+
+        self.cli_import("0020-libvirt.mbox.gz")
+        subj2 = subj + '\n[libvirt]  [PATCH v2] vcpupin: add clear feature'
+        self.check_cli(["search", "project:Libvirt"], stdout=subj2)
+        self.check_cli(["search", "project:Libvirt-python"], stdout=subj)
+
 class UnprivilegedImportTest(ImportTest):
     def setUp(self):
         self.create_superuser()
diff --git a/www/templates/project-detail.html b/www/templates/project-detail.html
index a172b1a..80fce3b 100644
--- a/www/templates/project-detail.html
+++ b/www/templates/project-detail.html
@@ -32,6 +32,15 @@
 <div class="status-content">
   <span class="fa fa-lg fa-git"></span><div>Git: <a href="{{ project.git }}">{{ project.git }}</a></div>
 </div>
+{% if project.get_subprojects %}
+<div class="status-content">
+  <span class="fa fa-lg fa-sitemap"></span><div>Subprojects:
+    {% for p in project.get_subprojects %}
+      <a href="{% url "project_detail" project=p %}">{{ p.name }}</a>
+    {% endfor %}
+  </div>
+</div>
+{% endif %}
 {% for status in project.extra_status %}
  <div class="status-content{% if status.kind %} status-{{ status.kind }}{% endif %}">
  {% if status.icon %}<span class="fa fa-lg fa-{{ status.icon }}"></span>
diff --git a/www/views.py b/www/views.py
index b3b76a1..eec226f 100644
--- a/www/views.py
+++ b/www/views.py
@@ -88,10 +88,10 @@ def prepare_series_list(request, sl):
     return [prepare_message(request, s, False) for s in sl]
 
 def prepare_projects():
-    return api.models.Project.objects.all().order_by('-display_order', 'name')
+    return api.models.Project.objects.filter(parent_project=None).order_by('-display_order', 'name')
 
 def view_project_list(request):
-    return render_page(request, "project-list.html", projects=prepare_projects)
+    return render_page(request, "project-list.html", projects=prepare_projects())
 
 def gen_page_links(total, cur_page, pagesize, extra_params):
     max_page = int((total + pagesize - 1) / pagesize)
-- 
2.14.3