From 4bb68dbb34b035adb7e1d9564002d557a10ef9d7 Mon Sep 17 00:00:00 2001 From: Stefano Apostolico Date: Wed, 9 Oct 2024 07:48:48 +0200 Subject: [PATCH] updates --- docs/src/contributing.md | 32 +++++++ src/country_workspace/config/urls.py | 6 -- src/country_workspace/models/base.py | 8 ++ src/country_workspace/models/household.py | 4 +- src/country_workspace/models/locations.py | 2 - src/country_workspace/web/static/favicon.ico | Bin 0 -> 15406 bytes .../workspaces/changelist.py | 21 +++-- src/country_workspace/workspaces/models.py | 4 +- src/country_workspace/workspaces/options.py | 31 ++++++- .../templates/workspace/w_submit_line.html | 2 +- tests/conftest.py | 20 +--- tests/extras/testutils/utils.py | 2 + tests/functional/conftest.py | 39 +++++--- tests/functional/test_f_login.py | 86 +++++++++++++++++- tests/test_user_flow.py | 7 +- 15 files changed, 203 insertions(+), 61 deletions(-) create mode 100644 src/country_workspace/web/static/favicon.ico diff --git a/docs/src/contributing.md b/docs/src/contributing.md index 6e299bc..dfcaca0 100644 --- a/docs/src/contributing.md +++ b/docs/src/contributing.md @@ -12,4 +12,36 @@ Install [uv](https://docs.astral.sh/uv/) ## Run tests + pytests tests + +## Run Selenium tests (ONLY) + + pytests tests -m selenium + + +## Run Selenium any tests + + pytests tests --selenium + + +!!! note + + You can disable selenium headless mode (show the browser activity on the screen) using `--show-browser` flag + + + + +## Run local server + + + ./manage.py runserver + + +!!! note + + To facililate developing you can use: + + export AUTHENTICATION_BACKENDS="country_workspace.security.backends.AnyUserAuthBackend" + + It works only if `DEBUG=True` diff --git a/src/country_workspace/config/urls.py b/src/country_workspace/config/urls.py index 8a89229..60f4613 100644 --- a/src/country_workspace/config/urls.py +++ b/src/country_workspace/config/urls.py @@ -1,6 +1,4 @@ -from django.conf import settings from django.conf.urls import include -from django.conf.urls.static import static from django.contrib import admin from django.urls import path @@ -10,7 +8,6 @@ urlpatterns = [ path(r"admin/", admin.site.urls), - # path(r"", admin.site.urls), path(r"security/", include("unicef_security.urls", namespace="security")), path(r"social/", include("social_django.urls", namespace="social")), path(r"accounts/", include("django.contrib.auth.urls")), @@ -19,6 +16,3 @@ path(r"__debug__/", include(debug_toolbar.urls)), path(r"", workspace.urls), ] - -if settings.DEBUG: - urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/src/country_workspace/models/base.py b/src/country_workspace/models/base.py index 81c166b..ce7d4a4 100644 --- a/src/country_workspace/models/base.py +++ b/src/country_workspace/models/base.py @@ -1,6 +1,7 @@ from typing import Any from django.db import models +from django.urls import reverse class BaseQuerySet(models.QuerySet["models.Model"]): @@ -24,3 +25,10 @@ class BaseModel(models.Model): class Meta: abstract = True + + def get_change_url(self, namespace="workspace"): + return reverse( + "%s:%s_%s_change" + % (namespace, self._meta.app_label, self._meta.model_name), + args=[self.pk], + ) diff --git a/src/country_workspace/models/household.py b/src/country_workspace/models/household.py index a4e0fed..72da224 100644 --- a/src/country_workspace/models/household.py +++ b/src/country_workspace/models/household.py @@ -7,7 +7,9 @@ class Household(BaseModel): - country_office = models.ForeignKey(Office, on_delete=models.CASCADE) + country_office = models.ForeignKey( + Office, on_delete=models.CASCADE, related_name="households" + ) program = models.ForeignKey(Program, on_delete=models.CASCADE) name = models.CharField(_("Name"), max_length=255) flex_fields = models.JSONField(default=dict, blank=True) diff --git a/src/country_workspace/models/locations.py b/src/country_workspace/models/locations.py index 08260d7..af2dbdf 100644 --- a/src/country_workspace/models/locations.py +++ b/src/country_workspace/models/locations.py @@ -1,5 +1,3 @@ -from typing import Any, Optional - from django.db import models from django.db.models import JSONField, Q, UniqueConstraint from django.utils.translation import gettext as _ diff --git a/src/country_workspace/web/static/favicon.ico b/src/country_workspace/web/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4c96af11704d16ef71c506b686ebe8293e120e54 GIT binary patch literal 15406 zcmeI232c-_6vzJvC6v2ZSJkX$^a%qA}M2!J40V4z{S0QLXB4QDx zC^xlbyWf^WP|*mAKqR86Qc%hb(n7oKmU1XWK7Vi7&ukh>L2W=YU-I&1-n?u6GdrEm zj8sdt)v%%9De6~8Y9}crw&y!`tYaeI!L{DnWso;SQATf)Cc=dsE@?oTctUguzi;51wPE4_G$1T>!v7U&^l4ypp<3k(Mbk*tLlx*Y5=LQ0{&ZIycAzPo^=MZoRI0PI5|1SjCM|6#bHTi7y zihai*_8@uxM*Om{+SooM@Kwy7iu#jQ&m69HOJCQd-I?rjJWWO3)8s*X_EOzrp59}> zGQbb%d}}L6*9Er6L4O*1wZYM4YGCrJKCtYMetytSeLVh`XOGZ>3)6IL-var>U4y?l z&|z?YCxQPF>Up$TZY9V)^FjGzXa_#thOb5UOLD6n_^-^Xil3FLW$gbf_Qt>-pZDPN zAhNO8^(&gI`sRd_LY;dUq<|u>!;!? ztgnN%^001TulWdlJwmKk135akSX|=tjK}x164?^-6PJh8BhanP6Y>3ML}MsFjo*Fv zAC;M^f$Uw)=W%ix1AmhCd@I8r#_wy6uhGJJ-ri)sU|z>Bqg<+G&2R&HgViTjt*V$W}qyp)A`so4h=v z=G1c`n**Phc)H!>RekaJN%OBNy_%B8hxT@L%<>65O4@M1pX-k zc22?(t)7M4ISC-R&mrIta0oaA90Cpjhk!%CA>a_GaRmHjoK=T(b1bB5YdlyD1mN8H zI?hBl+1Y6gxUX@S#SyjO{4)kEsqtWcBS5H^YR?_4CSIDF7R*;u&ZRF+c2hZj|1;G; zxKvm88LFGC7nJQxJ33zt4)I>Z=3XE_6VAEz1jhV}80X)CT>-v0pU|cRzIsaqg<0{l^KIyC=1e>%&`HjpbMj}g|DN_O@YUQ4J-%XvcujTU+cQU(A)i9* zeE3#;tml5AU-H|TK=)J}|GC(f_}oN(Mew^nN>!%?!*yu&Wcl1j`RG<4 z-v;f1j#@v;d6Q%FpZad*e=+H53zmWT{aJx?$ z<@1&($fY`7UH-q!P&e)^*5{_l-!-568&9z~wjo_p5z&prn`pTaKc6>3_kqRB)XTU> zOO55~di*zo1|cs+zMb{y67n2P`6y%JVSkg&A#`s;X8_c{s`+-ZzEqe0x9Lh{?(B=- z{enJv>$``45 zvQF_LU5q>v^66_gbeCHhirTy}LbjK>%}bM~C)=N@oXGzeb9cP`ZcR7wrp)~9T$iq{ z-2LrXJXXHleG)4ln`VJe!;|`)Xh^N#>DU}$jJ~MgeOaJm@5|`-&rmt<&aOr$13C`P zDPO4eHh1E4&(l}PY>fK^w$<@O{?_L*?B;RLH=c4HvHpPPu1Hh8%-1w2G(!G{>2Yi< z?gr-WByyiYxe$IN7Evd=%fs${WcIt<3{OXIGBLKq{Y5m+)=bubF0@6Fm2yuxo4dcE z)IWs|qnlU6x?5e|LA&KotV7tYg!aVCCw^v>Be}|N83!OkhTi?W^0d)4nvTygC%=*Bk=Ys+OT>(V-%7^bjzdrT zi{sC@g2mJL??5hn8G|mCN3xTQ7gcv&s&4g0s(1be>bpOz=ZM|g=7#+Sl6CD4keu#d zu04hSXTbQ*pnu7FwojseJ;>W)c0RB~mnXNMPC3XQKYy+Nr(<`n==|67XFYp{u^wHz yP&c0|n4U=<_P5C1)!MpnCU#EOA>a^j2si{B0uBL(fJ49`;1F;KI0XK`2>c0C`DFG0 literal 0 HcmV?d00001 diff --git a/src/country_workspace/workspaces/changelist.py b/src/country_workspace/workspaces/changelist.py index 3d3b1a7..d08f619 100644 --- a/src/country_workspace/workspaces/changelist.py +++ b/src/country_workspace/workspaces/changelist.py @@ -7,13 +7,16 @@ class WorkspaceChangeList(DjangoChangeList): def url_for_result(self, result): pk = getattr(result, self.pk_attname) - return reverse( - "%s:%s_%s_change" - % ( - self.model_admin.admin_site.namespace, - self.opts.app_label, - self.opts.model_name, - ), - args=(quote(pk),), - current_app=self.model_admin.admin_site.name, + return ( + reverse( + "%s:%s_%s_change" + % ( + self.model_admin.admin_site.namespace, + self.opts.app_label, + self.opts.model_name, + ), + args=(quote(pk),), + current_app=self.model_admin.admin_site.name, + ) + + self.preserved_filters ) diff --git a/src/country_workspace/workspaces/models.py b/src/country_workspace/workspaces/models.py index 28d6411..54d86ca 100644 --- a/src/country_workspace/workspaces/models.py +++ b/src/country_workspace/workspaces/models.py @@ -11,8 +11,8 @@ class CountryHousehold(global_models.Household): class Meta: proxy = True - # verbose_name = "Household" - # verbose_name_plural = "Households" + verbose_name = "Country Household" + verbose_name_plural = "Country Households" # app_label = "country_workspace" diff --git a/src/country_workspace/workspaces/options.py b/src/country_workspace/workspaces/options.py index beaa406..9c79614 100644 --- a/src/country_workspace/workspaces/options.py +++ b/src/country_workspace/workspaces/options.py @@ -1,3 +1,5 @@ +from urllib.parse import urlencode + from django.contrib import admin from django.contrib.admin.templatetags.admin_urls import add_preserved_filters from django.http import HttpResponseRedirect @@ -20,11 +22,29 @@ class WorkspaceModelAdmin(ExtraButtonsMixin, AdminFiltersMixin, admin.ModelAdmin "workspace/delete_selected_confirmation.html" ) delete_confirmation_template = "workspace/delete_confirmation.html" + preserve_filters = True def __init__(self, model, admin_site): self._selected_program = None super().__init__(model, admin_site) + def get_preserved_filters(self, request): + """ + Return the preserved filters querystring. + """ + match = request.resolver_match + if self.preserve_filters and match: + current_url = "%s:%s" % (match.app_name, match.url_name) + changelist_url = self.get_changelist_url(request) + if current_url == changelist_url: + preserved_filters = request.GET.urlencode() + else: + preserved_filters = request.GET.get("_changelist_filters") + + if preserved_filters: + return urlencode({"_changelist_filters": preserved_filters}) + return "" + def add_preserved_filters(self, request, base_url): preserved_filters = self.get_preserved_filters(request) preserved_qsl = self._get_preserved_qsl(request, preserved_filters) @@ -44,7 +64,7 @@ def get_changelist_url(self, request): % (self.admin_site.namespace, opts.app_label, opts.model_name), current_app=self.admin_site.name, ) - return self.add_preserved_filters(request, obj_url) + return obj_url def get_change_url(self, request, obj): opts = self.model._meta @@ -54,7 +74,7 @@ def get_change_url(self, request, obj): args=[obj.pk], current_app=self.admin_site.name, ) - return self.add_preserved_filters(request, obj_url) + return obj_url def get_changelist(self, request, **kwargs): from .changelist import WorkspaceChangeList @@ -68,6 +88,9 @@ def changeform_view(self, request, object_id=None, form_url="", extra_context=No extra_context["show_save_and_add_another"] = False extra_context["show_save_and_continue"] = True extra_context["show_save"] = False + extra_context["changelist_url2"] = self.add_preserved_filters( + request, self.get_changelist_url(request) + ) # extra_context = self.get_common_context( # request, object_id, **(extra_context or {}) # ) @@ -76,7 +99,9 @@ def changeform_view(self, request, object_id=None, form_url="", extra_context=No ) def _response_post_save(self, request, obj): - return HttpResponseRedirect(self.get_changelist_url(request)) + return HttpResponseRedirect( + self.add_preserved_filters(request, self.get_changelist_url(request)) + ) def response_add(self, request, obj, post_url_continue=None): return HttpResponseRedirect(self.get_change_url(request, obj)) diff --git a/src/country_workspace/workspaces/templates/workspace/w_submit_line.html b/src/country_workspace/workspaces/templates/workspace/w_submit_line.html index f4a5643..6e31eea 100644 --- a/src/country_workspace/workspaces/templates/workspace/w_submit_line.html +++ b/src/country_workspace/workspaces/templates/workspace/w_submit_line.html @@ -7,7 +7,7 @@ {% if show_save_and_continue %}{% endif %} {% if show_close %} {% url opts|admin_urlname:'changelist' as changelist_url %} - {% translate 'Close' %} + {% translate 'Close' %} {% endif %} {% if show_delete_link and original %} {% url opts|workspace_urlname:'delete' original.pk|admin_urlquote as delete_url %} diff --git a/tests/conftest.py b/tests/conftest.py index 97101f4..8b2b42e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,6 +34,7 @@ def pytest_addoption(parser): def pytest_configure(config): os.environ.update(DJANGO_SETTINGS_MODULE="country_workspace.config.settings") + os.environ.setdefault("STATIC_URL", "/static/") os.environ.setdefault("MEDIA_ROOT", "/tmp/static/") os.environ.setdefault("STATIC_ROOT", "/tmp/media/") os.environ.setdefault("TEST_EMAIL_SENDER", "sender@example.com") @@ -41,35 +42,16 @@ def pytest_configure(config): os.environ["MAILJET_API_KEY"] = "11" os.environ["MAILJET_SECRET_KEY"] = "11" - os.environ["FILE_STORAGE_DEFAULT"] = ( - "django.core.files.storage.FileSystemStorage?location=/tmp/hde/storage/" - ) - os.environ["FILE_STORAGE_STATIC"] = ( - "django.core.files.storage.FileSystemStorage?location=/tmp/hde/static/" - ) - os.environ["FILE_STORAGE_MEDIA"] = ( - "django.core.files.storage.FileSystemStorage?location=/tmp/hde/storage/" - ) - os.environ["FILE_STORAGE_HOPE"] = ( - "django.core.files.storage.FileSystemStorage?location=/tmp/hde/hope/" - ) os.environ["SOCIAL_AUTH_REDIRECT_IS_HTTPS"] = "0" os.environ["CELERY_TASK_ALWAYS_EAGER"] = "0" os.environ["SECURE_HSTS_PRELOAD"] = "0" os.environ["SECRET_KEY"] = "kugiugiuygiuygiuygiuhgiuhgiuhgiugiu" os.environ["LOGGING_LEVEL"] = "CRITICAL" - os.environ["GMAIL_USER"] = "11" - os.environ["GMAIL_PASSWORD"] = "11" from django.conf import settings settings.ALLOWED_HOSTS = ["127.0.0.1", "localhost"] settings.SIGNING_BACKEND = "testutils.signers.PlainSigner" - settings.MEDIA_ROOT = "/tmp/media" - settings.STATIC_ROOT = "/tmp/static" - os.makedirs(settings.MEDIA_ROOT, exist_ok=True) - os.makedirs(settings.STATIC_ROOT, exist_ok=True) - django.setup() diff --git a/tests/extras/testutils/utils.py b/tests/extras/testutils/utils.py index be42be2..fc76191 100644 --- a/tests/extras/testutils/utils.py +++ b/tests/extras/testutils/utils.py @@ -61,6 +61,8 @@ def wait_for_url(driver, url): from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.support.ui import WebDriverWait + if "://" not in url: + url = f"{driver.live_server.url}{url}" wait = WebDriverWait(driver, 10) wait.until(EC.url_contains(url)) diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index dca16c5..0a45ea8 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -3,7 +3,7 @@ import pytest from selenium.webdriver.common.by import By -from testutils.utils import wait_for +from testutils.utils import wait_for, wait_for_url Proxy = namedtuple("Proxy", "host,port") @@ -13,11 +13,6 @@ def pytest_configure(config): setattr(config.option, "driver", "chrome") -SELENIUM_DEFAULT_PAGE_LOAD_TIMEOUT = 3 -SELENIUM_DEFAULT_IMPLICITLY_WAIT = 1 -SELENIUM_DEFAULT_SCRIPT_TIMEOUT = 1 - - @contextlib.contextmanager def timeouts(driver, wait=None, page=None, script=None): from selenium.webdriver.common.timeouts import Timeouts @@ -45,24 +40,40 @@ def find_by_css(selenium, *args): @pytest.fixture -def chrome_options(request): - from selenium.webdriver.chrome.options import Options - - chrome_options = Options() +def chrome_options(request, chrome_options): if not request.config.getvalue("show_browser"): chrome_options.add_argument("--headless") - chrome_options.add_argument("--no-sandbox") - chrome_options.add_argument("--disable-dev-shm-usage") + chrome_options.add_argument("--allow-insecure-localhost") + chrome_options.add_argument("--disable-browser-side-navigation") + chrome_options.add_argument("--disable-dev-shm-usage") + chrome_options.add_argument("--disable-gpu") + chrome_options.add_argument("--disable-translate") + chrome_options.add_argument("--ignore-certificate-errors") + chrome_options.add_argument("--lang=en-GB") + chrome_options.add_argument("--no-sandbox") + chrome_options.add_argument("--proxy-bypass-list=*") + chrome_options.add_argument("--proxy-server='direct://'") + chrome_options.add_argument("--start-maximized") + + prefs = { + "profile.default_content_setting_values.notifications": 1 + } # explicitly allow notifications + chrome_options.add_experimental_option("prefs", prefs) return chrome_options @pytest.fixture -def selenium(monkeypatch, settings, driver): +def selenium(monkeypatch, live_server, settings, driver): + driver.with_timeouts = timeouts.__get__(driver) driver.set_input_value = set_input_value.__get__(driver) - + driver.live_server = live_server driver.wait_for = wait_for.__get__(driver) + driver.wait_for_url = wait_for_url.__get__(driver) driver.find_by_css = find_by_css.__get__(driver) + # driver.maximize_window() + # driver.fullscreen_window() + yield driver diff --git a/tests/functional/test_f_login.py b/tests/functional/test_f_login.py index 05e42c3..dd4d527 100644 --- a/tests/functional/test_f_login.py +++ b/tests/functional/test_f_login.py @@ -1,11 +1,91 @@ +from typing import TYPE_CHECKING + import pytest +from selenium.webdriver.common.by import By +from selenium.webdriver.support.select import Select + +if TYPE_CHECKING: + from country_workspace.workspaces.models import CountryHousehold + + +@pytest.fixture() +def office(db): + from testutils.factories import OfficeFactory + + co = OfficeFactory() + yield co + + +@pytest.fixture() +def program(office): + from testutils.factories import CountryProgramFactory + + return CountryProgramFactory( + country_office=office, + household_columns="__str__\nid\nxx", + individual_columns="__str__\nid\nxx", + ) + + +@pytest.fixture() +def household(program): + from testutils.factories import CountryHouseholdFactory + + return CountryHouseholdFactory( + program=program, country_office=program.country_office + ) @pytest.mark.selenium -def test_login(live_server, selenium, user): - selenium.get(f"{live_server.url}") +def test_login(selenium, user): + selenium.get(f"{selenium.live_server.url}") selenium.find_by_css("input[name=username").send_keys(user.username) selenium.find_by_css("input[name=password").send_keys(user._password) selenium.find_by_css("input[type=submit").click() - assert "Seems you do not have any tenant enabled." in selenium.page_source + + +@pytest.mark.selenium +def test_list_household(selenium, user, household: "CountryHousehold"): + from testutils.perms import user_grant_permissions + + from country_workspace.workspaces.models import CountryHousehold + + selenium.get(f"{selenium.live_server.url}") + with user_grant_permissions( + user, + [ + "workspaces.view_countryhousehold", + "workspaces.view_countryindividual", + ], + household.program.country_office, + ): + selenium.find_by_css("input[name=username").send_keys(user.username) + selenium.find_by_css("input[name=password").send_keys(user._password) + selenium.find_by_css("input[type=submit").click() + Select( + selenium.wait_for(By.CSS_SELECTOR, "select[name=tenant]") + ).select_by_visible_text(household.program.country_office.name) + selenium.find_by_css("input[type=submit").click() + selenium.wait_for(By.CSS_SELECTOR, "h1") + selenium.wait_for( + By.LINK_TEXT, str(CountryHousehold._meta.verbose_name_plural) + ).click() + selenium.wait_for_url("/workspaces/countryhousehold/") + selenium.wait_for(By.LINK_TEXT, str(household.name)).click() + selenium.wait_for_url(household.get_change_url()) + + selenium.wait_for(By.LINK_TEXT, "Close").click() + selenium.wait_for(By.CSS_SELECTOR, "h1") + + selenium.wait_for( + By.CSS_SELECTOR, + "#program__exact_program__isnull .select2-selection.select2-selection--single", + ).click() + el = selenium.wait_for(By.CSS_SELECTOR, ".select2-search__field") + el.send_keys(household.program.name) + selenium.wait_for( + By.CSS_SELECTOR, + "li.select2-results__option.select2-results__option--highlighted", + ).click() + selenium.wait_for_url("/workspaces/countryhousehold/?&program__exact=1") diff --git a/tests/test_user_flow.py b/tests/test_user_flow.py index 47a7363..cd4fb3a 100644 --- a/tests/test_user_flow.py +++ b/tests/test_user_flow.py @@ -67,6 +67,7 @@ def test_login(app, user, program, data): assert res.location == reverse("workspace:select_tenant") res = res.follow() assert "Seems you do not have any tenant enabled." in res.text + with user_grant_role(user, program.country_office): res = app.get(reverse("workspace:select_tenant"), user=user) res.forms["select-tenant"]["tenant"] = program.country_office.pk @@ -83,9 +84,13 @@ def test_login(app, user, program, data): ], program.country_office, ): + hh = program.country_office.households.first() res = app.get(reverse("workspace:select_tenant"), user=user) res.forms["select-tenant"]["tenant"] = program.country_office.pk res = res.forms["select-tenant"].submit() assert app.cookies["selected_tenant"] == program.country_office.slug res = res.follow() - res = res.click("Country households") + res = res.click("Country Households") + res = res.click(hh.name) + res = res.click("Close") + res.showbrowser()