diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000000..8716451434 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,57 @@ +name: Lint + +on: [push] + +jobs: + test: + name: Lint + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + - uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: setup ruff + run: pip install ruff + + - name: check ruff linter + run: ruff check --output-format=github . + + - name: check ruff formatter + run: ruff format --check . + + - name: Restore php-cs-fixer + id: cache-php-cs-fixer + uses: actions/cache/restore@v4 + with: + path: | + .php-cs-fixer.cache + tools/php-cs-fixer + key: ${{ runner.OS }}-${{ github.repository }}-phpcsfixer74 + + - uses: shivammathur/setup-php@v2 + with: + php-version: 7.4 + + - name: Install PHP-CS-Fixer + if: steps.cache-php-cs-fixer.outputs.cache-hit != 'true' + run: | + php composer.phar install --working-dir=tools/php-cs-fixer + + - name: Install PHP-CS-Fixer + run: | + tools/php-cs-fixer/vendor/bin/php-cs-fixer check + + - name: Save php-cs-fixer + id: save-php-cs-fixer + uses: actions/cache/save@v4 + with: + path: | + .php-cs-fixer.cache + tools/php-cs-fixer + key: ${{ runner.OS }}-${{ github.repository }}-phpcsfixer74 \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000000..03018cf796 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "[python]": { + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + } + }, + "python.analysis.typeCheckingMode": "basic" +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index c609305c9b..a51818a89a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,6 +21,7 @@ RUN apt-get -qq update && apt-get -qq install \ php-xdebug \ gettext \ rsync \ + mariadb-client \ --no-install-recommends && \ rm -r /var/lib/apt/lists/* @@ -40,6 +41,9 @@ RUN /usr/sbin/a2enmod expires rewrite && \ RUN echo "cy_GB.UTF-8 UTF-8" >> /etc/locale.gen RUN /usr/sbin/locale-gen +RUN curl -sSL https://install.python-poetry.org | python3 - +ENV PATH="/root/.local/bin:$PATH" + # Bind mount your working copy here WORKDIR /twfy diff --git a/bin/deploy.bash b/bin/deploy.bash index 3c53dd715a..3ffd8a6d20 100755 --- a/bin/deploy.bash +++ b/bin/deploy.bash @@ -11,9 +11,7 @@ export PATH="$DIR/vendor/bundle-bin:$PATH" # setup venv and python packages python3 -m venv .venv -source .venv/bin/activate -pip install --upgrade pip -pip install -r requirements.txt +poetry install # Now use compass to compile the SCSS: (cd "$DIR/www/docs/style" && bundle exec compass compile) diff --git a/classes/AlertView/Standard.php b/classes/AlertView/Standard.php index 0f07f09dcb..d8c5c1b8c5 100644 --- a/classes/AlertView/Standard.php +++ b/classes/AlertView/Standard.php @@ -25,7 +25,9 @@ public function display() { $this->checkInput(); $this->searchForConstituenciesAndMembers(); - if (!sizeof($this->data['errors']) && ($this->data['keyword'] || $this->data['pid'])) { + if ($this->data['step'] || $this->data['addword']) { + $this->processStep(); + } elseif (!$this->data['results'] == 'changes-abandoned' && !sizeof($this->data['errors']) && $this->data['submitted'] && ($this->data['keyword'] || $this->data['pid'])) { $this->addAlert(); } @@ -37,6 +39,7 @@ public function display() { return $this->data; } + # This only happens if we have an alert and want to do something to it. private function processAction() { $token = get_http_var('t'); $alert = $this->alert->check_token($token); @@ -48,7 +51,8 @@ private function processAction() { $success = $this->confirmAlert($token); if ($success) { $this->data['results'] = 'alert-confirmed'; - $this->data['criteria'] = \MySociety\TheyWorkForYou\Utility\Alert::prettifyCriteria($this->alert->criteria); + $this->data['criteria'] = $this->alert->criteria; + $this->data['display_criteria'] = \MySociety\TheyWorkForYou\Utility\Alert::prettifyCriteria($this->alert->criteria, $this->alert->ignore_speaker_votes); } } elseif ($action == 'Suspend') { $success = $this->suspendAlert($token); @@ -70,6 +74,8 @@ private function processAction() { if ($success) { $this->data['results'] = 'all-alerts-deleted'; } + } elseif ($action == 'Abandon') { + $this->data['results'] = 'changes-abandoned'; } if (!$success) { $this->data['results'] = 'alert-fail'; @@ -79,6 +85,50 @@ private function processAction() { $this->data['alert'] = $alert; } + # Process a screen in the alert creation wizard + private function processStep() { + # fetch a list of suggested terms. Need this for the define screen so we can filter out the suggested terms + # and not show them if the user goes back + if (($this->data['step'] == 'review' || $this->data['step'] == 'define') && !$this->data['shown_related']) { + $suggestions = []; + foreach ($this->data['keywords'] as $word) { + $terms = $this->alert->get_related_terms($word); + $terms = array_diff($terms, $this->data['keywords']); + if ($terms && count($terms)) { + $suggestions = array_merge($suggestions, $terms); + } + } + + if (count($suggestions) > 0) { + $this->data['step'] = 'add_vector_related'; + $this->data['suggestions'] = $suggestions; + } + # confirm the alert. Handles both creating and editing alerts + } elseif ($this->data['step'] == 'confirm') { + $success = true; + # if there's already an alert assume we are editing it and user must be logged in + if ($this->data['alert']) { + $success = $this->updateAlert($this->data['alert']['id'], $this->data); + if ($success) { + # reset all the data to stop anything getting confused + $this->data['results'] = 'alert-confirmed'; + $this->data['step'] = ''; + $this->data['pid'] = ''; + $this->data['alertsearch'] = ''; + $this->data['pc'] = ''; + $this->data['members'] = false; + $this->data['constituencies'] = []; + } else { + $this->data['results'] = 'alert-fail'; + $this->data['step'] = 'review'; + } + } else { + $success = $this->addAlert(); + $this->data['step'] = ''; + } + } + } + private function getBasicData() { global $this_page; @@ -93,12 +143,124 @@ private function getBasicData() { $this->data["email"] = trim(get_http_var("email")); $this->data['email_verified'] = false; } + + $this->data['token'] = get_http_var('t'); + $this->data['step'] = trim(get_http_var("step")); + $this->data['mp_step'] = trim(get_http_var("mp_step")); + $this->data['addword'] = trim(get_http_var("addword")); + $this->data['this_step'] = trim(get_http_var("this_step")); + $this->data['shown_related'] = get_http_var('shown_related'); + $this->data['match_all'] = get_http_var('match_all') == 'on'; $this->data['keyword'] = trim(get_http_var("keyword")); - $this->data['pid'] = trim(get_http_var("pid")); + $this->data['search_section'] = ''; $this->data['alertsearch'] = trim(get_http_var("alertsearch")); + $this->data['mp_search'] = trim(get_http_var("mp_search")); + $this->data['pid'] = trim(get_http_var("pid")); $this->data['pc'] = get_http_var('pc'); - $this->data['submitted'] = get_http_var('submitted') || $this->data['pid'] || $this->data['keyword']; - $this->data['token'] = get_http_var('t'); + $this->data['submitted'] = get_http_var('submitted') || $this->data['pid'] || $this->data['keyword'] || $this->data['step']; + $this->data['ignore_speaker_votes'] = get_http_var('ignore_speaker_votes'); + + if ($this->data['addword'] || $this->data['step']) { + $alert = $this->alert->check_token($this->data['token']); + + $criteria = ''; + $alert_ignore_speaker_votes = 0; + if ($alert) { + $criteria = $alert['criteria']; + $alert_ignore_speaker_votes = $alert['ignore_speaker_votes']; + } + + $ignore_speaker_votes = get_http_var('ignore_speaker_votes', $alert_ignore_speaker_votes); + $this->data['ignore_speaker_votes'] = ($ignore_speaker_votes == 'on' || $ignore_speaker_votes == 1); + + $this->data['alert'] = $alert; + + $this->data['alert_parts'] = \MySociety\TheyWorkForYou\Utility\Alert::prettifyCriteria($criteria, $alert_ignore_speaker_votes, true); + + $existing_rep = ''; + if (isset($this->data['alert_parts']['spokenby'])) { + $existing_rep = $this->data['alert_parts']['spokenby'][0]; + } + + $existing_section = ''; + if (count($this->data['alert_parts']['sections'])) { + $existing_section = $this->data['alert_parts']['sections'][0]; + } + + if ($this->data['alert_parts']['match_all']) { + $this->data['match_all'] = true; + } + + $words = get_http_var('words', $this->data['alert_parts']['words'], true); + + $this->data['words'] = []; + $this->data['keywords'] = []; + foreach ($words as $word) { + if (trim($word) != '') { + $this->data['keywords'][] = $word; + $this->data['words'][] = $this->wrap_phrase_in_quotes($word); + } + } + + $add_all_related = get_http_var('add_all_related'); + $this->data['add_all_related'] = $add_all_related; + $this->data['skip_keyword_terms'] = []; + + $selected_related_terms = get_http_var('selected_related_terms', [], true); + $this->data['selected_related_terms'] = $selected_related_terms; + + if ($this->data['step'] !== 'define') { + if ($add_all_related) { + $this->data['selected_related_terms'] = []; + $related_terms = get_http_var('related_terms', [], true); + foreach ($related_terms as $term) { + $this->data['skip_keyword_terms'][] = $term; + $this->data['keywords'][] = $term; + $this->data['words'][] = $this->wrap_phrase_in_quotes($term); + } + } else { + $this->data['skip_keyword_terms'] = $selected_related_terms; + foreach ($selected_related_terms as $term) { + $this->data['keywords'][] = $term; + $this->data['words'][] = $this->wrap_phrase_in_quotes($term); + } + } + } + $this->data['exclusions'] = trim(get_http_var("exclusions", implode('', $this->data['alert_parts']['exclusions']))); + $this->data['representative'] = trim(get_http_var("representative", $existing_rep)); + + $this->data['search_section'] = trim(get_http_var("search_section", $existing_section)); + + $separator = ' OR '; + if ($this->data['match_all']) { + $separator = ' '; + } + $this->data['keyword'] = implode($separator, $this->data['words']); + if ($this->data['exclusions']) { + $this->data['keyword'] = '(' . $this->data['keyword'] . ') -' . $this->data["exclusions"]; + } + + $this->data['results'] = ''; + + $this->getSearchSections(); + } else if ($this->data['mp_step'] == 'mp_alert') { + $alert = $this->alert->check_token($this->data['token']); + if ($alert) { + $ignore_speaker_votes = get_http_var('ignore_speaker_votes', $alert['ignore_speaker_votes']); + $this->data['ignore_speaker_votes'] = ($ignore_speaker_votes == 'on' || $ignore_speaker_votes == 1); + + $existing_rep = ''; + if (isset($alert['criteria'])) { + $alert_parts = \MySociety\TheyWorkForYou\Utility\Alert::prettifyCriteria($alert['criteria'], $alert['ignore_speaker_votes'], true); + $existing_rep = $alert_parts['spokenby'][0]; + $this->data['pid'] = $alert_parts['pid']; + } + $this->data['keyword'] = get_http_var('mp_search', $existing_rep); + } else { + $this->data['ignore_speaker_votes'] = get_http_var('ignore_speaker_votes'); + } + } # XXX probably should do something here if $alertsearch is set + $this->data['sign'] = get_http_var('sign'); $this->data['site'] = get_http_var('site'); $this->data['message'] = ''; @@ -106,6 +268,36 @@ private function getBasicData() { $ACTIONURL = new \MySociety\TheyWorkForYou\Url($this_page); $ACTIONURL->reset(); $this->data['actionurl'] = $ACTIONURL->generate(); + + } + + private function wrap_phrase_in_quotes($phrase) { + if (strpos($phrase, ' ') > 0) { + $phrase = '"' . trim($phrase, '"') . '"'; + } + + return $phrase; + } + + private function getRecentResults($text) { + global $SEARCHENGINE; + $se = new \SEARCHENGINE($text); + $this->data['search_result_count'] = $se->run_count(0, 10); + $se->run_search(0, 1, 'date'); + } + + private function getSearchSections() { + $this->data['sections'] = []; + if ($this->data['search_section']) { + foreach (explode(' ', $this->data['search_section']) as $section) { + $this->data['sections'][] = \MySociety\TheyWorkForYou\Utility\Alert::sectionToTitle($section); + } + } + } + + private function updateAlert($token) { + $success = $this->alert->update($token, $this->data); + return $success; } private function checkInput() { @@ -113,6 +305,12 @@ private function checkInput() { $errors = []; + # these are the initial screens and so cannot have any errors as we've not submitted + if (!$this->data['submitted'] || $this->data['step'] == 'define' || $this->data['mp_step'] == 'mp_alert') { + $this->data['errors'] = $errors; + return; + } + // Check each of the things the user has input. // If there is a problem with any of them, set an entry in the $errors array. // This will then be used to (a) indicate there were errors and (b) display @@ -131,6 +329,9 @@ private function checkInput() { } $text = $this->data['alertsearch']; + if ($this->data['mp_search']) { + $text = $this->data['mp_search']; + } if (!$text) { $text = $this->data['keyword']; } @@ -156,10 +357,48 @@ private function checkInput() { } private function searchForConstituenciesAndMembers() { - // Do the search - if ($this->data['alertsearch']) { - $this->data['members'] = \MySociety\TheyWorkForYou\Utility\Search::searchMemberDbLookupWithNames($this->data['alertsearch'], true); - [$this->data['constituencies'], $this->data['valid_postcode']] = \MySociety\TheyWorkForYou\Utility\Search::searchConstituenciesByQuery($this->data['alertsearch']); + if ($this->data['results'] == 'changes-abandoned') { + $this->data['members'] = false; + return; + } + + $text = $this->data['alertsearch']; + if ($this->data['mp_search']) { + $text = $this->data['mp_search']; + } + $errors = []; + if ($text != '') { + //$members_from_pids = array_values(\MySociety\TheyWorkForYou\Utility\Search::membersForIDs($this->data['alertsearch'])); + $members_from_names = []; + $names_from_pids = array_values(\MySociety\TheyWorkForYou\Utility\Search::speakerNamesForIDs($text)); + foreach ($names_from_pids as $name) { + $members_from_names = array_merge($members_from_names,\MySociety\TheyWorkForYou\Utility\Search::searchMemberDbLookupWithNames($name)); + } + $members_from_words = \MySociety\TheyWorkForYou\Utility\Search::searchMemberDbLookupWithNames($text, true); + $this->data['members'] = array_merge($members_from_words, $members_from_names); + [$this->data['constituencies'], $this->data['valid_postcode']] = \MySociety\TheyWorkForYou\Utility\Search::searchConstituenciesByQuery($text, false); + } elseif ($this->data['pid']) { + $MEMBER = new \MEMBER(['person_id' => $this->data['pid']]); + $this->data['members'] = [[ + "person_id" => $MEMBER->person_id, + "given_name" => $MEMBER->given_name, + "family_name" => $MEMBER->family_name, + "house" => $MEMBER->house_disp, + "title" => $MEMBER->title, + "lordofname" => $MEMBER->lordofname, + "constituency" => $MEMBER->constituency, + ]]; + } elseif (isset($this->data['representative']) && $this->data['representative'] != '') { + $this->data['members'] = \MySociety\TheyWorkForYou\Utility\Search::searchMemberDbLookupWithNames($this->data['representative'], true); + + $member_count = count($this->data['members']); + if ($member_count == 0) { + $errors["representative"] = gettext("No matching representative found"); + } elseif ($member_count > 1) { + $errors["representative"] = gettext("Multiple matching representatives found, please select one."); + } else { + $this->data['pid'] = $this->data['members'][0]['person_id']; + } } else { $this->data['members'] = []; } @@ -167,24 +406,42 @@ private function searchForConstituenciesAndMembers() { # If the above search returned one result for constituency # search by postcode, use it immediately if (isset($this->data['constituencies']) && count($this->data['constituencies']) == 1 && $this->data['valid_postcode']) { - $MEMBER = new \MEMBER(['constituency' => $this->data['constituencies'][0], 'house' => 1]); + $MEMBER = new \MEMBER(['constituency' => array_values($this->data['constituencies'])[0], 'house' => 1]); $this->data['pid'] = $MEMBER->person_id(); - $this->data['pc'] = $this->data['alertsearch']; + $this->data['pc'] = $text; unset($this->data['constituencies']); - $this->data['alertsearch'] = ''; } if (isset($this->data['constituencies'])) { $cons = []; foreach ($this->data['constituencies'] as $constituency) { try { - $MEMBER = new \MEMBER(['constituency' => $constituency, 'house' => 1]); + $MEMBER = new \MEMBER(['constituency' => $constituency]); $cons[$constituency] = $MEMBER; } catch (\MySociety\TheyWorkForYou\MemberException $e) { // do nothing } } $this->data['constituencies'] = $cons; + if (count($cons) == 1) { + $cons = array_values($cons); + $this->data['pid'] = $cons[0]->person_id(); + } + } + + if ($this->data['alertsearch'] && !$this->data['mp_step'] && ($this->data['pid'] || $this->data['members'] || $this->data['constituencies'])) { + if (count($this->data['members']) == 1) { + $this->data['pid'] = $this->data['members'][0]['person_id']; + } + $this->data['mp_step'] = 'mp_alert'; + $this->data['mp_search'] = $this->data['alertsearch']; + $this->data['alertsearch'] = ''; + } + + if (count($this->data["errors"]) > 0) { + $this->data["errors"] = array_merge($this->data["errors"], $errors); + } else { + $this->data["errors"] = $errors; } } @@ -203,8 +460,12 @@ private function addAlert() { $success = $this->alert->add($this->data, $confirm); if ($success > 0 && !$confirm) { + $this->data['step'] = ''; + $this->data['mp_step'] = ''; $result = 'alert-added'; } elseif ($success > 0) { + $this->data['step'] = ''; + $this->data['mp_step'] = ''; $result = 'alert-confirmation'; } elseif ($success == -2) { // we need to make sure we know that the person attempting to sign up @@ -230,7 +491,8 @@ private function addAlert() { $this->data['pc'] = ''; $this->data['results'] = $result; - $this->data['criteria'] = \MySociety\TheyWorkForYou\Utility\Alert::prettifyCriteria($this->alert->criteria); + $this->data['criteria'] = $this->alert->criteria; + $this->data['display_criteria'] = \MySociety\TheyWorkForYou\Utility\Alert::prettifyCriteria($this->alert->criteria, $this->alert->ignore_speaker_votes); } @@ -303,16 +565,121 @@ private function formatSearchMemberData() { } private function setUserData() { + if (!isset($this->data['criteria'])) { + $criteria = $this->data['keyword']; + if (!$this->data['match_all']) { + $has_or = strpos($criteria, ' OR ') !== false; + $missing_braces = strpos($criteria, '(') === false; + + if ($has_or && $missing_braces) { + $criteria = "($criteria)"; + } + } + if ($this->data['search_section']) { + $criteria .= " section:" . $this->data['search_section']; + } + if ($this->data['pid']) { + $criteria .= " speaker:" . $this->data['pid']; + } + $this->getRecentResults($criteria); + + $this->data['criteria'] = $criteria; + $this->data['display_criteria'] = \MySociety\TheyWorkForYou\Utility\Alert::prettifyCriteria($criteria); + } + if ($this->data['results'] == 'changes-abandoned') { + $this->data['members'] = false; + $this->data['alertsearch'] = ''; + } + + if ($this->data['alertsearch'] && !(isset($this->data['mistakes']['postcode_and']) || $this->data['members'] || $this->data['pid'])) { + $this->data['step'] = 'define'; + $this->data['words'] = [$this->data['alertsearch']]; + $this->data['keywords'] = [$this->data['alertsearch']]; + $this->data['exclusions'] = ''; + $this->data['representative'] = ''; + } elseif ($this->data['alertsearch'] && ($this->data['members'] || $this->data['pid'])) { + $this->data['mp_step'] = 'mp_alert'; + $this->data['mp_search'] = [$this->data['alertsearch']]; + } elseif ($this->data['members'] && $this->data['mp_step'] == 'mp_search') { + $this->data['mp_step'] = ''; + } + $this->data['current_mp'] = false; $this->data['alerts'] = []; + $this->data['keyword_alerts'] = []; + $this->data['speaker_alerts'] = []; + $this->data['spoken_alerts'] = []; + $this->data['own_member_alerts'] = []; + $this->data['all_keywords'] = []; + $this->data['own_mp_criteria'] = ''; + $own_mp_criteria = ''; + if ($this->data['email_verified']) { if ($this->user->postcode()) { $current_mp = new \MEMBER(['postcode' => $this->user->postcode()]); - if (!$this->alert->fetch_by_mp($this->data['email'], $current_mp->person_id())) { + if ($current_mp_alert = !$this->alert->fetch_by_mp($this->data['email'], $current_mp->person_id())) { $this->data['current_mp'] = $current_mp; + $own_mp_criteria = sprintf('speaker:%s', $current_mp->person_id()); } + $own_mp_criteria = $current_mp->full_name(); + $this->data['own_mp_criteria'] = $own_mp_criteria; } $this->data['alerts'] = \MySociety\TheyWorkForYou\Utility\Alert::forUser($this->data['email']); + foreach ($this->data['alerts'] as $alert) { + if (array_key_exists('spokenby', $alert) and sizeof($alert['spokenby']) == 1 and $alert['spokenby'][0] == $own_mp_criteria) { + $this->data['own_member_alerts'][] = $alert; + } elseif (array_key_exists('spokenby', $alert)) { + if (!array_key_exists($alert['spokenby'][0], $this->data['spoken_alerts'])) { + $this->data['spoken_alerts'][$alert['spokenby'][0]] = []; + } + $this->data['spoken_alerts'][$alert['spokenby'][0]][] = $alert; + } + } + foreach ($this->data['alerts'] as $alert) { + $term = implode(' ', $alert['words']); + $add = true; + if (array_key_exists('spokenby', $alert)) { + $add = false; + } elseif (array_key_exists($term, $this->data['spoken_alerts'])) { + $add = false; + $this->data['all_keywords'][] = $term; + $this->data['spoken_alerts'][$term][] = $alert; + } elseif ($term == $own_mp_criteria) { + $add = false; + $this->data['all_keywords'][] = $term; + $this->data['own_member_alerts'][] = $alert; + } elseif (\MySociety\TheyWorkForYou\Utility\Search::searchMemberDbLookupWithNames($term, true)) { + if (!array_key_exists($term, $this->data['spoken_alerts'])) { + $this->data['spoken_alerts'][$term] = []; + } + $add = false; + # need to add this to make it consistent so the front end know where to get the name + $alert['spokenby'] = [$term]; + $this->data['all_keywords'][] = $term; + $this->data['spoken_alerts'][$term][] = $alert; + } + if ($add) { + $this->data['all_keywords'][] = $term; + $this->data['keyword_alerts'][] = $alert; + } + } + } else { + if ($this->data['alertsearch'] && $this->data['pc']) { + $this->data['mp_step'] = 'mp_alert'; + } + } + if (count($this->data['alerts'])) { + $this->data['delete_token'] = $this->data['alerts'][0]['token']; + } + if ($this->data['addword'] != '' || ($this->data['step'] && count($this->data['errors']) > 0)) { + $this->data["step"] = get_http_var('this_step'); + } else { + $this->data['this_step'] = ''; + } + + $this->data["search_term"] = $this->data['alertsearch']; + if ($this->data['mp_search']) { + $this->data["search_term"] = $this->data['mp_search']; } } } diff --git a/classes/Homepage.php b/classes/Homepage.php index 00dddef5f1..19a8f71acf 100644 --- a/classes/Homepage.php +++ b/classes/Homepage.php @@ -40,7 +40,7 @@ public function display() { $data["commons_dissolved"] = isset($dissolution[1]); $data['regional'] = $this->getRegionalList(); - $data['popular_searches'] = $common->getPopularSearches(); + $data['popular_searches'] = []; # $common->getPopularSearches(); $data['urls'] = $this->getURLs(); $data['calendar'] = $this->getCalendarData(); $data['featured'] = $this->getEditorialContent(); diff --git a/classes/Subscription.php b/classes/Subscription.php index 71ec08dfe3..3eef7ffc37 100644 --- a/classes/Subscription.php +++ b/classes/Subscription.php @@ -122,7 +122,7 @@ private function update_subscription($form_data) { } } - if ($old_price >= $new_price) { + if ($old_price >= $new_price) { if ($this->stripe->schedule) { \Stripe\SubscriptionSchedule::release($this->stripe->schedule); } diff --git a/classes/Utility/Alert.php b/classes/Utility/Alert.php index 89da17ec29..d7ca0a33c4 100644 --- a/classes/Utility/Alert.php +++ b/classes/Utility/Alert.php @@ -9,6 +9,26 @@ */ class Alert { + public static function sectionToTitle($section) { + $section_map = [ + "uk" => gettext('All UK'), + "debates" => gettext('House of Commons debates'), + "whalls" => gettext('Westminster Hall debates'), + "lords" => gettext('House of Lords debates'), + "wrans" => gettext('Written answers'), + "wms" => gettext('Written ministerial statements'), + "standing" => gettext('Bill Committees'), + "future" => gettext('Future Business'), + "ni" => gettext('Northern Ireland Assembly Debates'), + "scotland" => gettext('All Scotland'), + "sp" => gettext('Scottish Parliament Debates'), + "spwrans" => gettext('Scottish Parliament Written answers'), + "wales" => gettext('Welsh parliament record'), + "lmqs" => gettext('Questions to the Mayor of London'), + ]; + + return $section_map[$section]; + } public static function detailsToCriteria($details) { $criteria = []; @@ -20,6 +40,10 @@ public static function detailsToCriteria($details) { $criteria[] = 'speaker:' . $details['pid']; } + if (!empty($details['search_section'])) { + $criteria[] = 'section:' . $details['search_section']; + } + $criteria = join(' ', $criteria); return $criteria; } @@ -33,7 +57,8 @@ public static function forUser($email) { $alerts = []; foreach ($q as $row) { - $criteria = self::prettifyCriteria($row['criteria']); + $criteria = self::prettifyCriteria($row['criteria'], $row['ignore_speaker_votes']); + $parts = self::prettifyCriteria($row['criteria'], $row['ignore_speaker_votes'], true); $token = $row['alert_id'] . '-' . $row['registrationtoken']; $status = 'confirmed'; @@ -43,36 +68,97 @@ public static function forUser($email) { $status = 'suspended'; } - $alerts[] = [ + $alert = [ 'token' => $token, 'status' => $status, 'criteria' => $criteria, 'raw' => $row['criteria'], + 'ignore_speaker_votes' => $row['ignore_speaker_votes'], + 'keywords' => [], + 'exclusions' => [], + 'sections' => [], ]; + + $alert = array_merge($alert, $parts); + + $alerts[] = $alert; } return $alerts; } - public static function prettifyCriteria($alert_criteria) { + public static function prettifyCriteria($alert_criteria, $ignore_speaker_votes = false, $as_parts = false) { $text = ''; + $parts = ['words' => [], 'sections' => [], 'exclusions' => [], 'match_all' => true, 'pid' => false]; if ($alert_criteria) { - $criteria = explode(' ', $alert_criteria); + # check for phrases + if (strpos($alert_criteria, ' OR ') !== false) { + $parts['match_all'] = false; + } + $alert_criteria = str_replace(' OR ', ' ', $alert_criteria); + $alert_criteria = str_replace(['(', ')'], '', $alert_criteria); + if (strpos($alert_criteria, '"') !== false) { + # match phrases + preg_match_all('/"([^"]*)"/', $alert_criteria, $phrases); + # and then remove them from the criteria + $alert_criteria = trim(preg_replace('/ +/', ' ', str_replace($phrases[0], "", $alert_criteria))); + + # and then create an array with the words and phrases + $criteria = []; + if ( $alert_criteria != "") { + $criteria = explode(' ', $alert_criteria); + } + $criteria = array_merge($criteria, $phrases[1]); + } else { + $criteria = explode(' ', $alert_criteria); + } $words = []; - $spokenby = array_values(\MySociety\TheyWorkForYou\Utility\Search::speakerNamesForIDs($alert_criteria)); + $exclusions = []; + $sections = []; + $sections_verbose = []; + $speaker_parts = \MySociety\TheyWorkForYou\Utility\Search::speakerNamesForIDs($alert_criteria); + $pids = array_keys($speaker_parts); + $spokenby = array_values($speaker_parts); + + if (count($pids) == 1) { + $parts['pid'] = $pids[0]; + } foreach ($criteria as $c) { - if (!preg_match('#^speaker:(\d+)#', $c, $m)) { + if (preg_match('#^section:(\w+)#', $c, $m)) { + $sections[] = $m[1]; + $sections_verbose[] = self::sectionToTitle($m[1]); + } elseif (strpos($c, '-') === 0) { + $exclusions[] = str_replace('-', '', $c); + } elseif (!preg_match('#^speaker:(\d+)#', $c, $m)) { $words[] = $c; } } if ($spokenby && count($words)) { $text = implode(' or ', $spokenby) . ' mentions [' . implode(' ', $words) . ']'; + $parts['spokenby'] = $spokenby; + $parts['words'] = $words; } elseif (count($words)) { $text = '[' . implode(' ', $words) . ']' . ' is mentioned'; + $parts['words'] = $words; } elseif ($spokenby) { $text = implode(' or ', $spokenby) . " speaks"; + if ($ignore_speaker_votes) { + $text .= " excluding votes"; + } + $parts['spokenby'] = $spokenby; } + + if ($sections) { + $text = $text . " in " . implode(' or ', $sections_verbose); + $parts['sections'] = $sections; + $parts['sections_verbose'] = $sections_verbose; + } + + $parts['exclusions'] = $exclusions; + } + if ($as_parts) { + return $parts; } return $text; } diff --git a/classes/Utility/Search.php b/classes/Utility/Search.php index 67200ee12e..6759e7fd2d 100644 --- a/classes/Utility/Search.php +++ b/classes/Utility/Search.php @@ -244,17 +244,25 @@ public static function searchMemberDbLookupWithNames($searchstring, $current_onl * Given a search term, find constituencies by name or postcode. * * @param string $searchterm The term to search for. + * @param bool $mp_only if true (default) only return westminster constituency if using a postcode, otherwise return all. * * @return array A list of the array of constituencies, then a boolean * saying whether it was a postcode used. */ - public static function searchConstituenciesByQuery($searchterm) { + public static function searchConstituenciesByQuery($searchterm, $mp_only=true) { if (validate_postcode($searchterm)) { // Looks like a postcode - can we find the constituency? - $constituency = Postcode::postcodeToConstituency($searchterm); - if ($constituency) { - return [ [$constituency], true ]; + if ($mp_only) { + $constituency = Postcode::postcodeToConstituency($searchterm); + if ($constituency) { + return [ [$constituency], true ]; + } + } else { + $constituencies = Postcode::postcodeToConstituencies($searchterm); + if ($constituencies) { + return [ $constituencies, true ]; + } } } @@ -297,6 +305,28 @@ public static function speakerNamesForIDs($searchstring) { return $speakers; } + /** + * get list of members of speaker IDs from search string + * + * @param string $searchstring The search string with the speaker:NNN text + * + * @return array Array with the speaker id string as key and speaker name as value + */ + + public static function membersForIDs($searchstring) { + $criteria = explode(' ', $searchstring); + $speakers = []; + + foreach ($criteria as $c) { + if (preg_match('#^speaker:(\d+)#', $c, $m)) { + $MEMBER = new \MEMBER(['person_id' => $m[1]]); + $speakers[$m[1]] = $MEMBER; + } + } + + return $speakers; + } + /** * replace speaker:NNNN with speaker:Name in search string * diff --git a/db/0025-add-vector-search-suggestions.sql b/db/0025-add-vector-search-suggestions.sql new file mode 100644 index 0000000000..8cc47224d9 --- /dev/null +++ b/db/0025-add-vector-search-suggestions.sql @@ -0,0 +1,5 @@ +CREATE TABLE `vector_search_suggestions` ( + `search_term` varchar(100) NOT NULL default '', + `search_suggestion` varchar(100) NOT NULL default '', + KEY `search_term` (`search_term`) +); diff --git a/db/0025-increase-personinfo-size.sql b/db/0025-increase-personinfo-size.sql new file mode 100644 index 0000000000..ff58be2dd6 --- /dev/null +++ b/db/0025-increase-personinfo-size.sql @@ -0,0 +1,3 @@ +ALTER TABLE `personinfo` +MODIFY COLUMN `data_value` +mediumtext NOT NULL; diff --git a/db/0026-add-ignore-votes-alerts.sql b/db/0026-add-ignore-votes-alerts.sql new file mode 100644 index 0000000000..2425dd136f --- /dev/null +++ b/db/0026-add-ignore-votes-alerts.sql @@ -0,0 +1 @@ +ALTER TABLE `alerts` ADD `ignore_speaker_votes` tinyint(1) NOT NULL default '0'; diff --git a/db/schema.sql b/db/schema.sql index 605b2d70a7..ae74e15e62 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -202,7 +202,7 @@ CREATE TABLE `moffice` ( CREATE TABLE `personinfo` ( `person_id` int(11) NOT NULL default '0', `data_key` varchar(100) NOT NULL default '', - `data_value` text NOT NULL, + `data_value` mediumtext NOT NULL, `lastupdate` timestamp NOT NULL default CURRENT_TIMESTAMP on update CURRENT_TIMESTAMP, UNIQUE KEY `personinfo_person_id_data_key` (`person_id`,`data_key`), KEY `person_id` (`person_id`) @@ -214,6 +214,12 @@ CREATE TABLE `postcode_lookup` ( PRIMARY KEY (`postcode`) ); +CREATE TABLE `vector_search_suggestions` ( + `search_term` varchar(100) NOT NULL default '', + `search_suggestion` varchar(100) NOT NULL default '', + KEY `search_term` (`search_term`) +); + -- each time we index, we increment the batch number; -- can use this to speed up search CREATE TABLE `indexbatch` ( @@ -301,6 +307,7 @@ CREATE TABLE `alerts` ( `confirmed` tinyint(1) NOT NULL default '0', `created` datetime NOT NULL default '0000-00-00 00:00:00', `postcode` varchar(10) NOT NULL default '', + `ignore_speaker_votes` tinyint(1) NOT NULL default '0', `lang` varchar(2) NOT NULL default 'en', PRIMARY KEY (`alert_id`), KEY `email` (`email`), diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000000..8d5feb1c1c --- /dev/null +++ b/poetry.lock @@ -0,0 +1,754 @@ +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "commonlib" +version = "0.1.0" +description = "Common python functions between mySociety projects. Basic config to recognize as package." +optional = false +python-versions = "^3.9" +files = [] +develop = false + +[package.dependencies] +pyyaml = "^6.0.1" + +[package.source] +type = "directory" +url = "commonlib" + +[[package]] +name = "greenlet" +version = "3.0.3" +description = "Lightweight in-process concurrent programming" +optional = false +python-versions = ">=3.7" +files = [ + {file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"}, + {file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"}, + {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"}, + {file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"}, + {file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"}, + {file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"}, + {file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"}, + {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"}, + {file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"}, + {file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"}, + {file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"}, + {file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"}, + {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"}, + {file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"}, + {file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"}, + {file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"}, + {file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"}, + {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"}, + {file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"}, + {file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"}, + {file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"}, + {file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"}, + {file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"}, + {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"}, + {file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"}, + {file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"}, + {file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"}, + {file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"}, + {file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"}, + {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"}, + {file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"}, + {file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"}, + {file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"}, + {file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"}, +] + +[package.extras] +docs = ["Sphinx", "furo"] +test = ["objgraph", "psutil"] + +[[package]] +name = "markdown-it-py" +version = "3.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.8" +files = [ + {file = "markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb"}, + {file = "markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +code-style = ["pre-commit (>=3.0,<4.0)"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins"] +profiling = ["gprof2dot"] +rtd = ["jupyter_sphinx", "mdit-py-plugins", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"] + +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mysqlclient" +version = "2.2.4" +description = "Python interface to MySQL" +optional = false +python-versions = ">=3.8" +files = [ + {file = "mysqlclient-2.2.4-cp310-cp310-win_amd64.whl", hash = "sha256:ac44777eab0a66c14cb0d38965572f762e193ec2e5c0723bcd11319cc5b693c5"}, + {file = "mysqlclient-2.2.4-cp311-cp311-win_amd64.whl", hash = "sha256:329e4eec086a2336fe3541f1ce095d87a6f169d1cc8ba7b04ac68bcb234c9711"}, + {file = "mysqlclient-2.2.4-cp312-cp312-win_amd64.whl", hash = "sha256:e1ebe3f41d152d7cb7c265349fdb7f1eca86ccb0ca24a90036cde48e00ceb2ab"}, + {file = "mysqlclient-2.2.4-cp38-cp38-win_amd64.whl", hash = "sha256:3c318755e06df599338dad7625f884b8a71fcf322a9939ef78c9b3db93e1de7a"}, + {file = "mysqlclient-2.2.4-cp39-cp39-win_amd64.whl", hash = "sha256:9d4c015480c4a6b2b1602eccd9846103fc70606244788d04aa14b31c4bd1f0e2"}, + {file = "mysqlclient-2.2.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d43987bb9626096a302ca6ddcdd81feaeca65ced1d5fe892a6a66b808326aa54"}, + {file = "mysqlclient-2.2.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4e80dcad884dd6e14949ac6daf769123223a52a6805345608bf49cdaf7bc8b3a"}, + {file = "mysqlclient-2.2.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:9d3310295cb682232cadc28abd172f406c718b9ada41d2371259098ae37779d3"}, + {file = "mysqlclient-2.2.4.tar.gz", hash = "sha256:33bc9fb3464e7d7c10b1eaf7336c5ff8f2a3d3b88bab432116ad2490beb3bf41"}, +] + +[[package]] +name = "numpy" +version = "1.26.4" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0"}, + {file = "numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4"}, + {file = "numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a"}, + {file = "numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2"}, + {file = "numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07"}, + {file = "numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71"}, + {file = "numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e"}, + {file = "numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a"}, + {file = "numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a"}, + {file = "numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20"}, + {file = "numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218"}, + {file = "numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b"}, + {file = "numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a"}, + {file = "numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0"}, + {file = "numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110"}, + {file = "numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c"}, + {file = "numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764"}, + {file = "numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd"}, + {file = "numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c"}, + {file = "numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6"}, + {file = "numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c"}, + {file = "numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0"}, + {file = "numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010"}, +] + +[[package]] +name = "pandas" +version = "2.2.1" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pandas-2.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8df8612be9cd1c7797c93e1c5df861b2ddda0b48b08f2c3eaa0702cf88fb5f88"}, + {file = "pandas-2.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0f573ab277252ed9aaf38240f3b54cfc90fff8e5cab70411ee1d03f5d51f3944"}, + {file = "pandas-2.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f02a3a6c83df4026e55b63c1f06476c9aa3ed6af3d89b4f04ea656ccdaaaa359"}, + {file = "pandas-2.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c38ce92cb22a4bea4e3929429aa1067a454dcc9c335799af93ba9be21b6beb51"}, + {file = "pandas-2.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c2ce852e1cf2509a69e98358e8458775f89599566ac3775e70419b98615f4b06"}, + {file = "pandas-2.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:53680dc9b2519cbf609c62db3ed7c0b499077c7fefda564e330286e619ff0dd9"}, + {file = "pandas-2.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:94e714a1cca63e4f5939cdce5f29ba8d415d85166be3441165edd427dc9f6bc0"}, + {file = "pandas-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f821213d48f4ab353d20ebc24e4faf94ba40d76680642fb7ce2ea31a3ad94f9b"}, + {file = "pandas-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c70e00c2d894cb230e5c15e4b1e1e6b2b478e09cf27cc593a11ef955b9ecc81a"}, + {file = "pandas-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e97fbb5387c69209f134893abc788a6486dbf2f9e511070ca05eed4b930b1b02"}, + {file = "pandas-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101d0eb9c5361aa0146f500773395a03839a5e6ecde4d4b6ced88b7e5a1a6403"}, + {file = "pandas-2.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7d2ed41c319c9fb4fd454fe25372028dfa417aacb9790f68171b2e3f06eae8cd"}, + {file = "pandas-2.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:af5d3c00557d657c8773ef9ee702c61dd13b9d7426794c9dfeb1dc4a0bf0ebc7"}, + {file = "pandas-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:06cf591dbaefb6da9de8472535b185cba556d0ce2e6ed28e21d919704fef1a9e"}, + {file = "pandas-2.2.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:88ecb5c01bb9ca927ebc4098136038519aa5d66b44671861ffab754cae75102c"}, + {file = "pandas-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:04f6ec3baec203c13e3f8b139fb0f9f86cd8c0b94603ae3ae8ce9a422e9f5bee"}, + {file = "pandas-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a935a90a76c44fe170d01e90a3594beef9e9a6220021acfb26053d01426f7dc2"}, + {file = "pandas-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c391f594aae2fd9f679d419e9a4d5ba4bce5bb13f6a989195656e7dc4b95c8f0"}, + {file = "pandas-2.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9d1265545f579edf3f8f0cb6f89f234f5e44ba725a34d86535b1a1d38decbccc"}, + {file = "pandas-2.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:11940e9e3056576ac3244baef2fedade891977bcc1cb7e5cc8f8cc7d603edc89"}, + {file = "pandas-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:4acf681325ee1c7f950d058b05a820441075b0dd9a2adf5c4835b9bc056bf4fb"}, + {file = "pandas-2.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9bd8a40f47080825af4317d0340c656744f2bfdb6819f818e6ba3cd24c0e1397"}, + {file = "pandas-2.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:df0c37ebd19e11d089ceba66eba59a168242fc6b7155cba4ffffa6eccdfb8f16"}, + {file = "pandas-2.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:739cc70eaf17d57608639e74d63387b0d8594ce02f69e7a0b046f117974b3019"}, + {file = "pandas-2.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9d3558d263073ed95e46f4650becff0c5e1ffe0fc3a015de3c79283dfbdb3df"}, + {file = "pandas-2.2.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4aa1d8707812a658debf03824016bf5ea0d516afdea29b7dc14cf687bc4d4ec6"}, + {file = "pandas-2.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:76f27a809cda87e07f192f001d11adc2b930e93a2b0c4a236fde5429527423be"}, + {file = "pandas-2.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:1ba21b1d5c0e43416218db63037dbe1a01fc101dc6e6024bcad08123e48004ab"}, + {file = "pandas-2.2.1.tar.gz", hash = "sha256:0ab90f87093c13f3e8fa45b48ba9f39181046e8f3317d3aadb2fffbb1b978572"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.26.0,<2", markers = "python_version >= \"3.12\""}, + {version = ">=1.22.4,<2", markers = "python_version < \"3.11\""}, + {version = ">=1.23.2,<2", markers = "python_version == \"3.11\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + +[[package]] +name = "pyarrow" +version = "15.0.2" +description = "Python library for Apache Arrow" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pyarrow-15.0.2-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:88b340f0a1d05b5ccc3d2d986279045655b1fe8e41aba6ca44ea28da0d1455d8"}, + {file = "pyarrow-15.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eaa8f96cecf32da508e6c7f69bb8401f03745c050c1dd42ec2596f2e98deecac"}, + {file = "pyarrow-15.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23c6753ed4f6adb8461e7c383e418391b8d8453c5d67e17f416c3a5d5709afbd"}, + {file = "pyarrow-15.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f639c059035011db8c0497e541a8a45d98a58dbe34dc8fadd0ef128f2cee46e5"}, + {file = "pyarrow-15.0.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:290e36a59a0993e9a5224ed2fb3e53375770f07379a0ea03ee2fce2e6d30b423"}, + {file = "pyarrow-15.0.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:06c2bb2a98bc792f040bef31ad3e9be6a63d0cb39189227c08a7d955db96816e"}, + {file = "pyarrow-15.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:f7a197f3670606a960ddc12adbe8075cea5f707ad7bf0dffa09637fdbb89f76c"}, + {file = "pyarrow-15.0.2-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:5f8bc839ea36b1f99984c78e06e7a06054693dc2af8920f6fb416b5bca9944e4"}, + {file = "pyarrow-15.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5e81dfb4e519baa6b4c80410421528c214427e77ca0ea9461eb4097c328fa33"}, + {file = "pyarrow-15.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3a4f240852b302a7af4646c8bfe9950c4691a419847001178662a98915fd7ee7"}, + {file = "pyarrow-15.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e7d9cfb5a1e648e172428c7a42b744610956f3b70f524aa3a6c02a448ba853e"}, + {file = "pyarrow-15.0.2-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:2d4f905209de70c0eb5b2de6763104d5a9a37430f137678edfb9a675bac9cd98"}, + {file = "pyarrow-15.0.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:90adb99e8ce5f36fbecbbc422e7dcbcbed07d985eed6062e459e23f9e71fd197"}, + {file = "pyarrow-15.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:b116e7fd7889294cbd24eb90cd9bdd3850be3738d61297855a71ac3b8124ee38"}, + {file = "pyarrow-15.0.2-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:25335e6f1f07fdaa026a61c758ee7d19ce824a866b27bba744348fa73bb5a440"}, + {file = "pyarrow-15.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:90f19e976d9c3d8e73c80be84ddbe2f830b6304e4c576349d9360e335cd627fc"}, + {file = "pyarrow-15.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a22366249bf5fd40ddacc4f03cd3160f2d7c247692945afb1899bab8a140ddfb"}, + {file = "pyarrow-15.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2a335198f886b07e4b5ea16d08ee06557e07db54a8400cc0d03c7f6a22f785f"}, + {file = "pyarrow-15.0.2-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e6d459c0c22f0b9c810a3917a1de3ee704b021a5fb8b3bacf968eece6df098f"}, + {file = "pyarrow-15.0.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:033b7cad32198754d93465dcfb71d0ba7cb7cd5c9afd7052cab7214676eec38b"}, + {file = "pyarrow-15.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:29850d050379d6e8b5a693098f4de7fd6a2bea4365bfd073d7c57c57b95041ee"}, + {file = "pyarrow-15.0.2-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:7167107d7fb6dcadb375b4b691b7e316f4368f39f6f45405a05535d7ad5e5058"}, + {file = "pyarrow-15.0.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e85241b44cc3d365ef950432a1b3bd44ac54626f37b2e3a0cc89c20e45dfd8bf"}, + {file = "pyarrow-15.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:248723e4ed3255fcd73edcecc209744d58a9ca852e4cf3d2577811b6d4b59818"}, + {file = "pyarrow-15.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ff3bdfe6f1b81ca5b73b70a8d482d37a766433823e0c21e22d1d7dde76ca33f"}, + {file = "pyarrow-15.0.2-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:f3d77463dee7e9f284ef42d341689b459a63ff2e75cee2b9302058d0d98fe142"}, + {file = "pyarrow-15.0.2-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:8c1faf2482fb89766e79745670cbca04e7018497d85be9242d5350cba21357e1"}, + {file = "pyarrow-15.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:28f3016958a8e45a1069303a4a4f6a7d4910643fc08adb1e2e4a7ff056272ad3"}, + {file = "pyarrow-15.0.2-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:89722cb64286ab3d4daf168386f6968c126057b8c7ec3ef96302e81d8cdb8ae4"}, + {file = "pyarrow-15.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cd0ba387705044b3ac77b1b317165c0498299b08261d8122c96051024f953cd5"}, + {file = "pyarrow-15.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad2459bf1f22b6a5cdcc27ebfd99307d5526b62d217b984b9f5c974651398832"}, + {file = "pyarrow-15.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58922e4bfece8b02abf7159f1f53a8f4d9f8e08f2d988109126c17c3bb261f22"}, + {file = "pyarrow-15.0.2-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:adccc81d3dc0478ea0b498807b39a8d41628fa9210729b2f718b78cb997c7c91"}, + {file = "pyarrow-15.0.2-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:8bd2baa5fe531571847983f36a30ddbf65261ef23e496862ece83bdceb70420d"}, + {file = "pyarrow-15.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6669799a1d4ca9da9c7e06ef48368320f5856f36f9a4dd31a11839dda3f6cc8c"}, + {file = "pyarrow-15.0.2.tar.gz", hash = "sha256:9c9bc803cb3b7bfacc1e96ffbfd923601065d9d3f911179d81e72d99fd74a3d9"}, +] + +[package.dependencies] +numpy = ">=1.16.6,<2" + +[[package]] +name = "pydantic" +version = "2.8.2" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic-2.8.2-py3-none-any.whl", hash = "sha256:73ee9fddd406dc318b885c7a2eab8a6472b68b8fb5ba8150949fc3db939f23c8"}, + {file = "pydantic-2.8.2.tar.gz", hash = "sha256:6f62c13d067b0755ad1c21a34bdd06c0c12625a22b0fc09c6b149816604f7c2a"}, +] + +[package.dependencies] +annotated-types = ">=0.4.0" +pydantic-core = "2.20.1" +typing-extensions = [ + {version = ">=4.12.2", markers = "python_version >= \"3.13\""}, + {version = ">=4.6.1", markers = "python_version < \"3.13\""}, +] + +[package.extras] +email = ["email-validator (>=2.0.0)"] + +[[package]] +name = "pydantic-core" +version = "2.20.1" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:3acae97ffd19bf091c72df4d726d552c473f3576409b2a7ca36b2f535ffff4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:41f4c96227a67a013e7de5ff8f20fb496ce573893b7f4f2707d065907bffdbd6"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f239eb799a2081495ea659d8d4a43a8f42cd1fe9ff2e7e436295c38a10c286a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53e431da3fc53360db73eedf6f7124d1076e1b4ee4276b36fb25514544ceb4a3"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1f62b2413c3a0e846c3b838b2ecd6c7a19ec6793b2a522745b0869e37ab5bc1"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d41e6daee2813ecceea8eda38062d69e280b39df793f5a942fa515b8ed67953"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d482efec8b7dc6bfaedc0f166b2ce349df0011f5d2f1f25537ced4cfc34fd98"}, + {file = "pydantic_core-2.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e93e1a4b4b33daed65d781a57a522ff153dcf748dee70b40c7258c5861e1768a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e7c4ea22b6739b162c9ecaaa41d718dfad48a244909fe7ef4b54c0b530effc5a"}, + {file = "pydantic_core-2.20.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4f2790949cf385d985a31984907fecb3896999329103df4e4983a4a41e13e840"}, + {file = "pydantic_core-2.20.1-cp310-none-win32.whl", hash = "sha256:5e999ba8dd90e93d57410c5e67ebb67ffcaadcea0ad973240fdfd3a135506250"}, + {file = "pydantic_core-2.20.1-cp310-none-win_amd64.whl", hash = "sha256:512ecfbefef6dac7bc5eaaf46177b2de58cdf7acac8793fe033b24ece0b9566c"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d2a8fa9d6d6f891f3deec72f5cc668e6f66b188ab14bb1ab52422fe8e644f312"}, + {file = "pydantic_core-2.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:175873691124f3d0da55aeea1d90660a6ea7a3cfea137c38afa0a5ffabe37b88"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:37eee5b638f0e0dcd18d21f59b679686bbd18917b87db0193ae36f9c23c355fc"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25e9185e2d06c16ee438ed39bf62935ec436474a6ac4f9358524220f1b236e43"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:150906b40ff188a3260cbee25380e7494ee85048584998c1e66df0c7a11c17a6"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ad4aeb3e9a97286573c03df758fc7627aecdd02f1da04516a86dc159bf70121"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3f3ed29cd9f978c604708511a1f9c2fdcb6c38b9aae36a51905b8811ee5cbf1"}, + {file = "pydantic_core-2.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0dae11d8f5ded51699c74d9548dcc5938e0804cc8298ec0aa0da95c21fff57b"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:faa6b09ee09433b87992fb5a2859efd1c264ddc37280d2dd5db502126d0e7f27"}, + {file = "pydantic_core-2.20.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9dc1b507c12eb0481d071f3c1808f0529ad41dc415d0ca11f7ebfc666e66a18b"}, + {file = "pydantic_core-2.20.1-cp311-none-win32.whl", hash = "sha256:fa2fddcb7107e0d1808086ca306dcade7df60a13a6c347a7acf1ec139aa6789a"}, + {file = "pydantic_core-2.20.1-cp311-none-win_amd64.whl", hash = "sha256:40a783fb7ee353c50bd3853e626f15677ea527ae556429453685ae32280c19c2"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:595ba5be69b35777474fa07f80fc260ea71255656191adb22a8c53aba4479231"}, + {file = "pydantic_core-2.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a4f55095ad087474999ee28d3398bae183a66be4823f753cd7d67dd0153427c9"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9aa05d09ecf4c75157197f27cdc9cfaeb7c5f15021c6373932bf3e124af029f"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e97fdf088d4b31ff4ba35db26d9cc472ac7ef4a2ff2badeabf8d727b3377fc52"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc633a9fe1eb87e250b5c57d389cf28998e4292336926b0b6cdaee353f89a237"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d573faf8eb7e6b1cbbcb4f5b247c60ca8be39fe2c674495df0eb4318303137fe"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26dc97754b57d2fd00ac2b24dfa341abffc380b823211994c4efac7f13b9e90e"}, + {file = "pydantic_core-2.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:33499e85e739a4b60c9dac710c20a08dc73cb3240c9a0e22325e671b27b70d24"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bebb4d6715c814597f85297c332297c6ce81e29436125ca59d1159b07f423eb1"}, + {file = "pydantic_core-2.20.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:516d9227919612425c8ef1c9b869bbbee249bc91912c8aaffb66116c0b447ebd"}, + {file = "pydantic_core-2.20.1-cp312-none-win32.whl", hash = "sha256:469f29f9093c9d834432034d33f5fe45699e664f12a13bf38c04967ce233d688"}, + {file = "pydantic_core-2.20.1-cp312-none-win_amd64.whl", hash = "sha256:035ede2e16da7281041f0e626459bcae33ed998cca6a0a007a5ebb73414ac72d"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:0827505a5c87e8aa285dc31e9ec7f4a17c81a813d45f70b1d9164e03a813a686"}, + {file = "pydantic_core-2.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19c0fa39fa154e7e0b7f82f88ef85faa2a4c23cc65aae2f5aea625e3c13c735a"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa223cd1e36b642092c326d694d8bf59b71ddddc94cdb752bbbb1c5c91d833b"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c336a6d235522a62fef872c6295a42ecb0c4e1d0f1a3e500fe949415761b8a19"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7eb6a0587eded33aeefea9f916899d42b1799b7b14b8f8ff2753c0ac1741edac"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:70c8daf4faca8da5a6d655f9af86faf6ec2e1768f4b8b9d0226c02f3d6209703"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9fa4c9bf273ca41f940bceb86922a7667cd5bf90e95dbb157cbb8441008482c"}, + {file = "pydantic_core-2.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:11b71d67b4725e7e2a9f6e9c0ac1239bbc0c48cce3dc59f98635efc57d6dac83"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:270755f15174fb983890c49881e93f8f1b80f0b5e3a3cc1394a255706cabd203"}, + {file = "pydantic_core-2.20.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c81131869240e3e568916ef4c307f8b99583efaa60a8112ef27a366eefba8ef0"}, + {file = "pydantic_core-2.20.1-cp313-none-win32.whl", hash = "sha256:b91ced227c41aa29c672814f50dbb05ec93536abf8f43cd14ec9521ea09afe4e"}, + {file = "pydantic_core-2.20.1-cp313-none-win_amd64.whl", hash = "sha256:65db0f2eefcaad1a3950f498aabb4875c8890438bc80b19362cf633b87a8ab20"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:4745f4ac52cc6686390c40eaa01d48b18997cb130833154801a442323cc78f91"}, + {file = "pydantic_core-2.20.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a8ad4c766d3f33ba8fd692f9aa297c9058970530a32c728a2c4bfd2616d3358b"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41e81317dd6a0127cabce83c0c9c3fbecceae981c8391e6f1dec88a77c8a569a"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04024d270cf63f586ad41fff13fde4311c4fc13ea74676962c876d9577bcc78f"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eaad4ff2de1c3823fddf82f41121bdf453d922e9a238642b1dedb33c4e4f98ad"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26ab812fa0c845df815e506be30337e2df27e88399b985d0bb4e3ecfe72df31c"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3c5ebac750d9d5f2706654c638c041635c385596caf68f81342011ddfa1e5598"}, + {file = "pydantic_core-2.20.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2aafc5a503855ea5885559eae883978c9b6d8c8993d67766ee73d82e841300dd"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:4868f6bd7c9d98904b748a2653031fc9c2f85b6237009d475b1008bfaeb0a5aa"}, + {file = "pydantic_core-2.20.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa2f457b4af386254372dfa78a2eda2563680d982422641a85f271c859df1987"}, + {file = "pydantic_core-2.20.1-cp38-none-win32.whl", hash = "sha256:225b67a1f6d602de0ce7f6c1c3ae89a4aa25d3de9be857999e9124f15dab486a"}, + {file = "pydantic_core-2.20.1-cp38-none-win_amd64.whl", hash = "sha256:6b507132dcfc0dea440cce23ee2182c0ce7aba7054576efc65634f080dbe9434"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:b03f7941783b4c4a26051846dea594628b38f6940a2fdc0df00b221aed39314c"}, + {file = "pydantic_core-2.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1eedfeb6089ed3fad42e81a67755846ad4dcc14d73698c120a82e4ccf0f1f9f6"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:635fee4e041ab9c479e31edda27fcf966ea9614fff1317e280d99eb3e5ab6fe2"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:77bf3ac639c1ff567ae3b47f8d4cc3dc20f9966a2a6dd2311dcc055d3d04fb8a"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ed1b0132f24beeec5a78b67d9388656d03e6a7c837394f99257e2d55b461611"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6514f963b023aeee506678a1cf821fe31159b925c4b76fe2afa94cc70b3222b"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10d4204d8ca33146e761c79f83cc861df20e7ae9f6487ca290a97702daf56006"}, + {file = "pydantic_core-2.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2d036c7187b9422ae5b262badb87a20a49eb6c5238b2004e96d4da1231badef1"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9ebfef07dbe1d93efb94b4700f2d278494e9162565a54f124c404a5656d7ff09"}, + {file = "pydantic_core-2.20.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:6b9d9bb600328a1ce523ab4f454859e9d439150abb0906c5a1983c146580ebab"}, + {file = "pydantic_core-2.20.1-cp39-none-win32.whl", hash = "sha256:784c1214cb6dd1e3b15dd8b91b9a53852aed16671cc3fbe4786f4f1db07089e2"}, + {file = "pydantic_core-2.20.1-cp39-none-win_amd64.whl", hash = "sha256:d2fe69c5434391727efa54b47a1e7986bb0186e72a41b203df8f5b0a19a4f669"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a45f84b09ac9c3d35dfcf6a27fd0634d30d183205230a0ebe8373a0e8cfa0906"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d02a72df14dfdbaf228424573a07af10637bd490f0901cee872c4f434a735b94"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2b27e6af28f07e2f195552b37d7d66b150adbaa39a6d327766ffd695799780f"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:084659fac3c83fd674596612aeff6041a18402f1e1bc19ca39e417d554468482"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:242b8feb3c493ab78be289c034a1f659e8826e2233786e36f2893a950a719bb6"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:38cf1c40a921d05c5edc61a785c0ddb4bed67827069f535d794ce6bcded919fc"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:e0bbdd76ce9aa5d4209d65f2b27fc6e5ef1312ae6c5333c26db3f5ade53a1e99"}, + {file = "pydantic_core-2.20.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:254ec27fdb5b1ee60684f91683be95e5133c994cc54e86a0b0963afa25c8f8a6"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:407653af5617f0757261ae249d3fba09504d7a71ab36ac057c938572d1bc9331"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:c693e916709c2465b02ca0ad7b387c4f8423d1db7b4649c551f27a529181c5ad"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b5ff4911aea936a47d9376fd3ab17e970cc543d1b68921886e7f64bd28308d1"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:177f55a886d74f1808763976ac4efd29b7ed15c69f4d838bbd74d9d09cf6fa86"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:964faa8a861d2664f0c7ab0c181af0bea66098b1919439815ca8803ef136fc4e"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:4dd484681c15e6b9a977c785a345d3e378d72678fd5f1f3c0509608da24f2ac0"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f6d6cff3538391e8486a431569b77921adfcdef14eb18fbf19b7c0a5294d4e6a"}, + {file = "pydantic_core-2.20.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6d511cc297ff0883bc3708b465ff82d7560193169a8b93260f74ecb0a5e08a7"}, + {file = "pydantic_core-2.20.1.tar.gz", hash = "sha256:26ca695eeee5f9f1aeeb211ffc12f10bcb6f71e2989988fda61dabd65db878d4"}, +] + +[package.dependencies] +typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" + +[[package]] +name = "pygments" +version = "2.18.0" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, + {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +description = "Extensions to the standard Python datetime module" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +files = [ + {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, + {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, +] + +[package.dependencies] +six = ">=1.5" + +[[package]] +name = "pytz" +version = "2024.1" +description = "World timezone definitions, modern and historical" +optional = false +python-versions = "*" +files = [ + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, +] + +[[package]] +name = "pyyaml" +version = "6.0.1" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.6" +files = [ + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "rich" +version = "13.7.1" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.7.0" +files = [ + {file = "rich-13.7.1-py3-none-any.whl", hash = "sha256:4edbae314f59eb482f54e9e30bf00d33350aaa94f4bfcd4e9e3110e64d0d7222"}, + {file = "rich-13.7.1.tar.gz", hash = "sha256:9be308cb1fe2f1f57d67ce99e95af38a1e2bc71ad9813b0e247cf7ffbcc3a432"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + +[[package]] +name = "rich-click" +version = "1.7.4" +description = "Format click help output nicely with rich" +optional = false +python-versions = ">=3.7" +files = [ + {file = "rich-click-1.7.4.tar.gz", hash = "sha256:7ce5de8e4dc0333aec946113529b3eeb349f2e5d2fafee96b9edf8ee36a01395"}, + {file = "rich_click-1.7.4-py3-none-any.whl", hash = "sha256:e363655475c60fec5a3e16a1eb618118ed79e666c365a36006b107c17c93ac4e"}, +] + +[package.dependencies] +click = ">=7" +rich = ">=10.7.0" +typing-extensions = "*" + +[package.extras] +dev = ["flake8", "flake8-docstrings", "mypy", "packaging", "pre-commit", "pytest", "pytest-cov", "types-setuptools"] + +[[package]] +name = "ruff" +version = "0.6.2" +description = "An extremely fast Python linter and code formatter, written in Rust." +optional = false +python-versions = ">=3.7" +files = [ + {file = "ruff-0.6.2-py3-none-linux_armv6l.whl", hash = "sha256:5c8cbc6252deb3ea840ad6a20b0f8583caab0c5ef4f9cca21adc5a92b8f79f3c"}, + {file = "ruff-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:17002fe241e76544448a8e1e6118abecbe8cd10cf68fde635dad480dba594570"}, + {file = "ruff-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3dbeac76ed13456f8158b8f4fe087bf87882e645c8e8b606dd17b0b66c2c1158"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:094600ee88cda325988d3f54e3588c46de5c18dae09d683ace278b11f9d4d534"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:316d418fe258c036ba05fbf7dfc1f7d3d4096db63431546163b472285668132b"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d72b8b3abf8a2d51b7b9944a41307d2f442558ccb3859bbd87e6ae9be1694a5d"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:2aed7e243be68487aa8982e91c6e260982d00da3f38955873aecd5a9204b1d66"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d371f7fc9cec83497fe7cf5eaf5b76e22a8efce463de5f775a1826197feb9df8"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8f310d63af08f583363dfb844ba8f9417b558199c58a5999215082036d795a1"}, + {file = "ruff-0.6.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7db6880c53c56addb8638fe444818183385ec85eeada1d48fc5abe045301b2f1"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1175d39faadd9a50718f478d23bfc1d4da5743f1ab56af81a2b6caf0a2394f23"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939f9c86d51635fe486585389f54582f0d65b8238e08c327c1534844b3bb9a"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d0d62ca91219f906caf9b187dea50d17353f15ec9bb15aae4a606cd697b49b4c"}, + {file = "ruff-0.6.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7438a7288f9d67ed3c8ce4d059e67f7ed65e9fe3aa2ab6f5b4b3610e57e3cb56"}, + {file = "ruff-0.6.2-py3-none-win32.whl", hash = "sha256:279d5f7d86696df5f9549b56b9b6a7f6c72961b619022b5b7999b15db392a4da"}, + {file = "ruff-0.6.2-py3-none-win_amd64.whl", hash = "sha256:d9f3469c7dd43cd22eb1c3fc16926fb8258d50cb1b216658a07be95dd117b0f2"}, + {file = "ruff-0.6.2-py3-none-win_arm64.whl", hash = "sha256:f28fcd2cd0e02bdf739297516d5643a945cc7caf09bd9bcb4d932540a5ea4fa9"}, + {file = "ruff-0.6.2.tar.gz", hash = "sha256:239ee6beb9e91feb8e0ec384204a763f36cb53fb895a1a364618c6abb076b3be"}, +] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.32" +description = "Database Abstraction Library" +optional = false +python-versions = ">=3.7" +files = [ + {file = "SQLAlchemy-2.0.32-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0c9045ecc2e4db59bfc97b20516dfdf8e41d910ac6fb667ebd3a79ea54084619"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1467940318e4a860afd546ef61fefb98a14d935cd6817ed07a228c7f7c62f389"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5954463675cb15db8d4b521f3566a017c8789222b8316b1e6934c811018ee08b"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167e7497035c303ae50651b351c28dc22a40bb98fbdb8468cdc971821b1ae533"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b27dfb676ac02529fb6e343b3a482303f16e6bc3a4d868b73935b8792edb52d0"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bf2360a5e0f7bd75fa80431bf8ebcfb920c9f885e7956c7efde89031695cafb8"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-win32.whl", hash = "sha256:306fe44e754a91cd9d600a6b070c1f2fadbb4a1a257b8781ccf33c7067fd3e4d"}, + {file = "SQLAlchemy-2.0.32-cp310-cp310-win_amd64.whl", hash = "sha256:99db65e6f3ab42e06c318f15c98f59a436f1c78179e6a6f40f529c8cc7100b22"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:21b053be28a8a414f2ddd401f1be8361e41032d2ef5884b2f31d31cb723e559f"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b178e875a7a25b5938b53b006598ee7645172fccafe1c291a706e93f48499ff5"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:723a40ee2cc7ea653645bd4cf024326dea2076673fc9d3d33f20f6c81db83e1d"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:295ff8689544f7ee7e819529633d058bd458c1fd7f7e3eebd0f9268ebc56c2a0"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:49496b68cd190a147118af585173ee624114dfb2e0297558c460ad7495f9dfe2"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:acd9b73c5c15f0ec5ce18128b1fe9157ddd0044abc373e6ecd5ba376a7e5d961"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-win32.whl", hash = "sha256:9365a3da32dabd3e69e06b972b1ffb0c89668994c7e8e75ce21d3e5e69ddef28"}, + {file = "SQLAlchemy-2.0.32-cp311-cp311-win_amd64.whl", hash = "sha256:8bd63d051f4f313b102a2af1cbc8b80f061bf78f3d5bd0843ff70b5859e27924"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6bab3db192a0c35e3c9d1560eb8332463e29e5507dbd822e29a0a3c48c0a8d92"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:19d98f4f58b13900d8dec4ed09dd09ef292208ee44cc9c2fe01c1f0a2fe440e9"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cd33c61513cb1b7371fd40cf221256456d26a56284e7d19d1f0b9f1eb7dd7e8"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d6ba0497c1d066dd004e0f02a92426ca2df20fac08728d03f67f6960271feec"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2b6be53e4fde0065524f1a0a7929b10e9280987b320716c1509478b712a7688c"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:916a798f62f410c0b80b63683c8061f5ebe237b0f4ad778739304253353bc1cb"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-win32.whl", hash = "sha256:31983018b74908ebc6c996a16ad3690301a23befb643093fcfe85efd292e384d"}, + {file = "SQLAlchemy-2.0.32-cp312-cp312-win_amd64.whl", hash = "sha256:4363ed245a6231f2e2957cccdda3c776265a75851f4753c60f3004b90e69bfeb"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b8afd5b26570bf41c35c0121801479958b4446751a3971fb9a480c1afd85558e"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c750987fc876813f27b60d619b987b057eb4896b81117f73bb8d9918c14f1cad"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ada0102afff4890f651ed91120c1120065663506b760da4e7823913ebd3258be"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:78c03d0f8a5ab4f3034c0e8482cfcc415a3ec6193491cfa1c643ed707d476f16"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:3bd1cae7519283ff525e64645ebd7a3e0283f3c038f461ecc1c7b040a0c932a1"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-win32.whl", hash = "sha256:01438ebcdc566d58c93af0171c74ec28efe6a29184b773e378a385e6215389da"}, + {file = "SQLAlchemy-2.0.32-cp37-cp37m-win_amd64.whl", hash = "sha256:4979dc80fbbc9d2ef569e71e0896990bc94df2b9fdbd878290bd129b65ab579c"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c742be912f57586ac43af38b3848f7688863a403dfb220193a882ea60e1ec3a"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:62e23d0ac103bcf1c5555b6c88c114089587bc64d048fef5bbdb58dfd26f96da"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:251f0d1108aab8ea7b9aadbd07fb47fb8e3a5838dde34aa95a3349876b5a1f1d"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ef18a84e5116340e38eca3e7f9eeaaef62738891422e7c2a0b80feab165905f"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:3eb6a97a1d39976f360b10ff208c73afb6a4de86dd2a6212ddf65c4a6a2347d5"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:0c1c9b673d21477cec17ab10bc4decb1322843ba35b481585facd88203754fc5"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-win32.whl", hash = "sha256:c41a2b9ca80ee555decc605bd3c4520cc6fef9abde8fd66b1cf65126a6922d65"}, + {file = "SQLAlchemy-2.0.32-cp38-cp38-win_amd64.whl", hash = "sha256:8a37e4d265033c897892279e8adf505c8b6b4075f2b40d77afb31f7185cd6ecd"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:52fec964fba2ef46476312a03ec8c425956b05c20220a1a03703537824b5e8e1"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:328429aecaba2aee3d71e11f2477c14eec5990fb6d0e884107935f7fb6001632"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85a01b5599e790e76ac3fe3aa2f26e1feba56270023d6afd5550ed63c68552b3"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf04784797dcdf4c0aa952c8d234fa01974c4729db55c45732520ce12dd95b4"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4488120becf9b71b3ac718f4138269a6be99a42fe023ec457896ba4f80749525"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:14e09e083a5796d513918a66f3d6aedbc131e39e80875afe81d98a03312889e6"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-win32.whl", hash = "sha256:0d322cc9c9b2154ba7e82f7bf25ecc7c36fbe2d82e2933b3642fc095a52cfc78"}, + {file = "SQLAlchemy-2.0.32-cp39-cp39-win_amd64.whl", hash = "sha256:7dd8583df2f98dea28b5cd53a1beac963f4f9d087888d75f22fcc93a07cf8d84"}, + {file = "SQLAlchemy-2.0.32-py3-none-any.whl", hash = "sha256:e567a8793a692451f706b363ccf3c45e056b67d90ead58c3bc9471af5d212202"}, + {file = "SQLAlchemy-2.0.32.tar.gz", hash = "sha256:c1b88cc8b02b6a5f0efb0345a03672d4c897dc7d92585176f88c67346f565ea8"}, +] + +[package.dependencies] +greenlet = {version = "!=0.4.17", markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} +typing-extensions = ">=4.6.0" + +[package.extras] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] +aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] +aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (!=0.4.17)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] +mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5)"] +mssql = ["pyodbc"] +mssql-pymssql = ["pymssql"] +mssql-pyodbc = ["pyodbc"] +mypy = ["mypy (>=0.910)"] +mysql = ["mysqlclient (>=1.4.0)"] +mysql-connector = ["mysql-connector-python"] +oracle = ["cx_oracle (>=8)"] +oracle-oracledb = ["oracledb (>=1.0.1)"] +postgresql = ["psycopg2 (>=2.7)"] +postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] +postgresql-pg8000 = ["pg8000 (>=1.29.1)"] +postgresql-psycopg = ["psycopg (>=3.0.7)"] +postgresql-psycopg2binary = ["psycopg2-binary"] +postgresql-psycopg2cffi = ["psycopg2cffi"] +postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] +pymysql = ["pymysql"] +sqlcipher = ["sqlcipher3_binary"] + +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + +[[package]] +name = "tzdata" +version = "2024.1" +description = "Provider of IANA time zone data" +optional = false +python-versions = ">=2" +files = [ + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, +] + +[metadata] +lock-version = "2.0" +python-versions = "^3.9" +content-hash = "1dafed89107d96e38dfb14ba659e5ed4666a4de947774cac8240e8f04973318d" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000..281e4fc3ff --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,58 @@ +[tool.poetry] +name = "twfy-tools" +version = "0.1.0" +description = "" +authors = ["Alex Parsons "] +readme = "README.md" +packages = [{include = "twfy_tools", from = "src"}] + +[tool.poetry.dependencies] +python = "^3.9" +rich-click = "1.7.4" +pandas = "2.2.1" +pyarrow = "15.0.2" +mysqlclient = "2.2.4" +pyyaml = "6.0.1" +commonlib = {path = "commonlib"} +pydantic = "^2.8.2" +sqlalchemy = "^2.0.32" + + +[tool.poetry.group.dev.dependencies] +ruff = "^0.6.1" + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" + + +[tool.ruff] +extend-exclude = ["migrations", "commonlib", "scripts/historic"] + +[tool.ruff.lint] +select = [ + "E", + # flake8 + "F", + # isort + "I", +] +ignore = [ + # line too long, sorted with formatter where it can be + "E501", +] + + +[tool.ruff.lint.isort] +known-first-party = ["hub"] +section-order = [ + "future", + "standard-library", + "django", + "third-party", + "first-party", + "local-folder" +] + +[tool.ruff.lint.isort.sections] +django = ["django"] \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 4edba748cc..0000000000 --- a/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -rich-click==1.7.4 -pandas==2.2.1 -pyarrow==15.0.2 -mysqlclient==2.2.4 -PyYAML==6.0.1 --e commonlib \ No newline at end of file diff --git a/script/lint b/script/lint index d7a12d38c6..e8c75589e9 100755 --- a/script/lint +++ b/script/lint @@ -5,4 +5,7 @@ if [ ! -f tools/php-cs-fixer/vendor/bin/php-cs-fixer ]; then php composer.phar install --working-dir=tools/php-cs-fixer fi -tools/php-cs-fixer/vendor/bin/php-cs-fixer fix \ No newline at end of file +tools/php-cs-fixer/vendor/bin/php-cs-fixer fix + +poetry run ruff check . --fix +poetry run ruff format . \ No newline at end of file diff --git a/scripts/alertmailer.php b/scripts/alertmailer.php index 03faba7b63..7766461c92 100644 --- a/scripts/alertmailer.php +++ b/scripts/alertmailer.php @@ -195,6 +195,7 @@ function mlog($message) { continue; } $criteria_raw = $alertitem['criteria']; + $include_votes = $alertitem['ignore_speaker_votes'] == 0; if (preg_match('#\bOR\b#', $criteria_raw)) { $criteria_raw = "($criteria_raw)"; } @@ -249,7 +250,7 @@ function mlog($message) { mlog(", hits " . $total_results . ", time " . (getmicrotime() - $start) . "\n"); # Divisions - if (preg_match('#^speaker:(\d+)$#', $criteria_raw, $m)) { + if ($include_votes && preg_match('#^speaker:(\d+)$#', $criteria_raw, $m)) { $pid = $m[1]; $q = $db->query('SELECT * FROM persondivisionvotes pdv JOIN divisions USING(division_id) WHERE person_id=:person_id AND pdv.lastupdate >= :time', [ diff --git a/scripts/division_io.py b/scripts/division_io.py index bc2e0d8228..2a81df9877 100644 --- a/scripts/division_io.py +++ b/scripts/division_io.py @@ -7,8 +7,6 @@ """ -import re -import sys from enum import Enum from pathlib import Path from typing import cast @@ -17,9 +15,9 @@ import MySQLdb import pandas as pd import rich_click as click +from pylib.mysociety import config from rich import print from rich.prompt import Prompt -from pylib.mysociety import config repository_path = Path(__file__).parent.parent @@ -40,7 +38,6 @@ class TitlePriority(str, Enum): @classmethod def get_priority(cls, priority: str) -> int: - lookup = { cls.ORIGINAL_HEADER: 1, cls.PARLIAMENT_DESCRIBED: 5, @@ -104,7 +101,7 @@ def df_to_db(df: pd.DataFrame, *, new_priority: TitlePriority, verbose: bool = F # get all divisions with a title_priority below or equal to current priority existing_df = pd.read_sql( - f"SELECT division_id, title_priority FROM divisions", + "SELECT division_id, title_priority FROM divisions", db_connection, ) existing_df["int_title_priority"] = existing_df["title_priority"].apply( @@ -227,15 +224,43 @@ def export_division_data(verbose: bool = False): # Create an export of the divisions data # Join with hansard table to get the gid of the debate as well # This is broadly the same as the old public whip dump + # Need to do this union to capture a set of old votes + # associated with policies - without a corresponding GID divisions_query = """ - SELECT + ( SELECT + division_id, + house as chamber, + CASE + WHEN division_id like '%-cy%' THEN 'cy' ELSE 'en' + END as language, + divisions.gid as source_gid, + hansard_debate.gid as debate_gid, + division_title, + yes_text, + no_text, + division_date, + division_number, + yes_total, + no_total, + absent_total, + both_total, + majority_vote, + lastupdate, + title_priority + FROM divisions + JOIN hansard + USING (gid) + JOIN hansard as hansard_debate + on hansard_debate.epobject_id = hansard.subsection_id) + UNION + (SELECT division_id, house as chamber, CASE WHEN division_id like '%-cy%' THEN 'cy' ELSE 'en' END as language, divisions.gid as source_gid, - hansard_debate.gid as debate_gid, + NULL as debate_gid, -- No matching records, so debate_gid is NULL division_title, yes_text, no_text, @@ -248,11 +273,10 @@ def export_division_data(verbose: bool = False): majority_vote, lastupdate, title_priority - FROM divisions - JOIN hansard - USING (gid) - JOIN hansard as hansard_debate - on hansard_debate.epobject_id = hansard.subsection_id + FROM + divisions + WHERE + divisions.gid = ''); """ # Export of all voting information diff --git a/scripts/download_parliament_portraits.py b/scripts/download_parliament_portraits.py index 43c4c5e873..f9e0e40d6a 100644 --- a/scripts/download_parliament_portraits.py +++ b/scripts/download_parliament_portraits.py @@ -1,4 +1,4 @@ -''' +""" Python 3 Downloads thumbnails @@ -9,15 +9,14 @@ Pillow everypolitician-popolo -''' +""" import os -import csv +from os.path import exists from tempfile import gettempdir -from PIL import Image from urllib.request import urlretrieve -from os.path import exists +from PIL import Image from popolo_data.importer import Popolo small_image_folder = r"..\www\docs\images\mps" @@ -25,64 +24,71 @@ def get_id_lookup(): - """ - create id lookup from popolo file - convert datadotparl_id to parlparse - """ - people_url = "https://github.com/mysociety/parlparse/raw/master/members/people.json" - pop = Popolo.from_url(people_url) - count = 0 - lookup = {} - print ("Creating id lookup") - for p in pop.persons: - id = p.id - datadotparl = p.identifier_value("datadotparl_id") - if datadotparl: - lookup[datadotparl] = id[-5:] - count += 1 - print (count, len(pop.persons)) - return lookup - -image_format = "https://members-api.parliament.uk/api/Members/{0}/Portrait?CropType=ThreeFour" + """ + create id lookup from popolo file + convert datadotparl_id to parlparse + """ + people_url = "https://github.com/mysociety/parlparse/raw/master/members/people.json" + pop = Popolo.from_url(people_url) + count = 0 + lookup = {} + print("Creating id lookup") + for p in pop.persons: + id = p.id + datadotparl = p.identifier_value("datadotparl_id") + if datadotparl: + lookup[datadotparl] = id[-5:] + count += 1 + print(count, len(pop.persons)) + return lookup + + +image_format = ( + "https://members-api.parliament.uk/api/Members/{0}/Portrait?CropType=ThreeFour" +) + + def get_image_url(id): - return image_format.format(id) + return image_format.format(id) + def download_and_resize(mp_id, parlparse): - filename = "{0}.jpg".format(parlparse) - alt_filename = "{0}.jpeg".format(parlparse) - small_path = os.path.join(small_image_folder, filename) - small_path_alt = os.path.join(small_image_folder, alt_filename) - large_path = os.path.join(large_image_folder, filename) - temp_path = os.path.join(gettempdir(),"{0}.jpg".format(mp_id)) - image_url = get_image_url(mp_id) - try: - urlretrieve(image_url, temp_path) - except Exception: - return None - print ("downloaded: {0}".format(image_url)) - image = Image.open(temp_path) - if exists(large_path) is False: - image.thumbnail((120, 160)) - image.save(large_path, quality=95) - if not exists(small_path) and not exists(small_path_alt): - image.thumbnail((60, 80)) - image.save(small_path, quality=95) - image.close() - os.remove(temp_path) + filename = "{0}.jpg".format(parlparse) + alt_filename = "{0}.jpeg".format(parlparse) + small_path = os.path.join(small_image_folder, filename) + small_path_alt = os.path.join(small_image_folder, alt_filename) + large_path = os.path.join(large_image_folder, filename) + temp_path = os.path.join(gettempdir(), "{0}.jpg".format(mp_id)) + image_url = get_image_url(mp_id) + try: + urlretrieve(image_url, temp_path) + except Exception: + return None + print("downloaded: {0}".format(image_url)) + image = Image.open(temp_path) + if exists(large_path) is False: + image.thumbnail((120, 160)) + image.save(large_path, quality=95) + if not exists(small_path) and not exists(small_path_alt): + image.thumbnail((60, 80)) + image.save(small_path, quality=95) + image.close() + os.remove(temp_path) + def get_images(): - """ - fetch image if available - """ - lookup = get_id_lookup() - - for datadotparl, parlparse in lookup.items(): - - filename = "{0}.jpg".format(parlparse) - small_path = os.path.join(small_image_folder, filename) - large_path = os.path.join(large_image_folder, filename) - if exists(large_path) is False or exists(small_path) is False: - download_and_resize(datadotparl, parlparse) + """ + fetch image if available + """ + lookup = get_id_lookup() + + for datadotparl, parlparse in lookup.items(): + filename = "{0}.jpg".format(parlparse) + small_path = os.path.join(small_image_folder, filename) + large_path = os.path.join(large_image_folder, filename) + if exists(large_path) is False or exists(small_path) is False: + download_and_resize(datadotparl, parlparse) + if __name__ == "__main__": - get_images() + get_images() diff --git a/scripts/future-fetch.py b/scripts/future-fetch.py index 986d66d79a..d155531170 100644 --- a/scripts/future-fetch.py +++ b/scripts/future-fetch.py @@ -1,48 +1,51 @@ #!/usr/bin/env python3 # encoding: utf-8 +import datetime import json import os -import sys import re +import sys import urllib.request -import MySQLdb -import datetime +import MySQLdb # Set up commonlib pylib package_dir = os.path.abspath(os.path.split(__file__)[0]) sys.path.append(os.path.normpath(package_dir + "/../commonlib/pylib")) # And from that, get the config -from mysociety import config +from mysociety import config # noqa:E402 + config.set_file(os.path.abspath(package_dir + "/../conf/general")) # And now we have config, find parlparse -sys.path.append(os.path.normpath(config.get('PWMEMBERS') + '../pyscraper')) +sys.path.append(os.path.normpath(config.get("PWMEMBERS") + "../pyscraper")) # This name matching could be done a lot better -from resolvemembernames import memberList -from lords.resolvenames import lordsList +from lords.resolvenames import lordsList # noqa:E402 +from resolvemembernames import memberList # noqa:E402 -CALENDAR_LINK = 'https://whatson.parliament.uk/%(place)s/%(iso)s/' -CALENDAR_BASE = 'https://whatson-api.parliament.uk/calendar/events/list.json?queryParameters.startDate=%(date)s' +CALENDAR_LINK = "https://whatson.parliament.uk/%(place)s/%(iso)s/" +CALENDAR_BASE = "https://whatson-api.parliament.uk/calendar/events/list.json?queryParameters.startDate=%(date)s" positions = {} def fetch_url(date): - data = CALENDAR_BASE % {'date': date} + data = CALENDAR_BASE % {"date": date} data = urllib.request.urlopen(data) data = json.load(data) return data + def get_calendar_events(): date = datetime.date.today() data = fetch_url(date) - data = sorted(data, key=lambda x: x['StartDate'] + str(x['SortOrder'])) + data = sorted(data, key=lambda x: x["StartDate"] + str(x["SortOrder"])) for event in data: yield Entry(event) + def make_time(t): if t and len(t) == 5: t += ":00" @@ -54,109 +57,120 @@ class Entry(object): modified = None deleted = 0 link_calendar = None - link_external = '' - body = 'uk' + link_external = "" + body = "uk" chamber = None event_date = None time_start = None time_end = None - committee_name = '' - debate_type = '' - title = '' + committee_name = "" + debate_type = "" + title = "" witnesses = None - witnesses_str = '' - location = '' + witnesses_str = "" + location = "" def __init__(self, event): - house = event['House'] # Lords / Commons / Joint - chamber = event['Type'] # Select & Joint Committees, General Committee, Grand Committee, Main Chamber, Westminster Hall - - if chamber == 'Select & Joint Committees': - house_url = 'committees' - if house == 'Joint': - self.chamber = 'Joint Committee' + house = event["House"] # Lords / Commons / Joint + chamber = event[ + "Type" + ] # Select & Joint Committees, General Committee, Grand Committee, Main Chamber, Westminster Hall + + if chamber == "Select & Joint Committees": + house_url = "committees" + if house == "Joint": + self.chamber = "Joint Committee" else: - self.chamber = '%s: Select Committee' % house + self.chamber = "%s: Select Committee" % house else: - self.chamber = '%s: %s' % (house, chamber) + self.chamber = "%s: %s" % (house, chamber) house_url = house.lower() # Two separate ID flows, for committees and not, it appears # We only have the one primary key - self.id = event['Id'] - if house_url == 'committees': + self.id = event["Id"] + if house_url == "committees": self.id += 1000000 - self.event_date = event['StartDate'][0:10] - self.time_start = make_time(event['StartTime']) - self.time_end = make_time(event['EndTime']) - self.link_calendar = CALENDAR_LINK % {'place': house_url, 'iso': self.event_date} - - if event['Category'] == "Prime Minister's Question Time": - self.debate_type = 'Oral questions' - self.title = event['Category'] + self.event_date = event["StartDate"][0:10] + self.time_start = make_time(event["StartTime"]) + self.time_end = make_time(event["EndTime"]) + self.link_calendar = CALENDAR_LINK % { + "place": house_url, + "iso": self.event_date, + } + + if event["Category"] == "Prime Minister's Question Time": + self.debate_type = "Oral questions" + self.title = event["Category"] else: - self.debate_type = event['Category'] - self.title = event['Description'] or '' + self.debate_type = event["Category"] + self.title = event["Description"] or "" - committee = event['Committee'] + committee = event["Committee"] if committee: - self.committee_name = committee['Description'] or '' - subject = (committee['Inquiries'] or [{}])[0].get('Description') + self.committee_name = committee["Description"] or "" + subject = (committee["Inquiries"] or [{}])[0].get("Description") if subject and self.title: - self.title += ': ' + subject + self.title += ": " + subject elif subject: self.title = subject self.people = [] - for member in event['Members']: - id = str(member['Id']) + for member in event["Members"]: + id = str(member["Id"]) match = memberList.match_by_mnis(id, self.event_date) if not match: match = lordsList.match_by_mnis(id, self.event_date) if match: self.people.append( - int(match['id'].replace('uk.org.publicwhip/person/', '')) + int(match["id"].replace("uk.org.publicwhip/person/", "")) ) self.witnesses = [] witnesses_str = [] - for activity in event['EventActivities'] or []: - for attendee in activity['Attendees']: - m = re.match(r'\b(\w+ \w+ MP)', attendee) + for activity in event["EventActivities"] or []: + for attendee in activity["Attendees"]: + m = re.match(r"\b(\w+ \w+ MP)", attendee) if m: mp = m.group(1) id, name, cons = memberList.matchfullnamecons( mp, None, self.event_date ) if id: - pid = int(id.replace('uk.org.publicwhip/person/', '')) + pid = int(id.replace("uk.org.publicwhip/person/", "")) mp_link = '%s' % (pid, mp) self.witnesses.append(pid) witnesses_str.append(attendee.replace(mp, mp_link)) continue witnesses_str.append(attendee) - self.witnesses_str = '\n'.join(witnesses_str) + self.witnesses_str = "\n".join(witnesses_str) - self.location = event['Location'] or '' + self.location = event["Location"] or "" def get_tuple(self): return ( - self.id, self.deleted, - self.link_calendar, self.link_external, - self.body, self.chamber, - self.event_date, self.time_start, self.time_end, + self.id, + self.deleted, + self.link_calendar, + self.link_external, + self.body, + self.chamber, + self.event_date, + self.time_start, + self.time_end, self.committee_name, self.debate_type, self.title, self.witnesses_str, self.location, - ) + ) def add(self): # TODO This function needs to insert into Xapian as well, or store to # insert in one go at the end - db_cursor.execute("""INSERT INTO future ( + db_cursor.execute( + """INSERT INTO future ( id, modified, deleted, link_calendar, link_external, body, chamber, @@ -170,8 +184,9 @@ def add(self): %s, %s, %s, %s, %s, %s, %s, %s - )""", self.get_tuple() - ) + )""", + self.get_tuple(), + ) self.update_people(delete_old=False) @@ -181,14 +196,14 @@ def update_people(self, delete_old=True): if delete_old: db_cursor.execute( - 'DELETE FROM future_people where calendar_id = %s', - (self.id,)) + "DELETE FROM future_people where calendar_id = %s", (self.id,) + ) db_cursor.executemany( - '''INSERT INTO future_people(calendar_id, person_id, witness) - VALUES (%s, %s, %s)''', - new_people + new_witnesses - ) + """INSERT INTO future_people(calendar_id, person_id, witness) + VALUES (%s, %s, %s)""", + new_people + new_witnesses, + ) def update(self): event_tuple = self.get_tuple() @@ -213,27 +228,27 @@ def update(self): WHERE id = %s """, - event_tuple[1:] + (self.id,) - ) + event_tuple[1:] + (self.id,), + ) self.update_people() + db_connection = MySQLdb.connect( - host=config.get('TWFY_DB_HOST'), - db=config.get('TWFY_DB_NAME'), - user=config.get('TWFY_DB_USER'), - passwd=config.get('TWFY_DB_PASS'), - charset='utf8', - ) + host=config.get("TWFY_DB_HOST"), + db=config.get("TWFY_DB_NAME"), + user=config.get("TWFY_DB_USER"), + passwd=config.get("TWFY_DB_PASS"), + charset="utf8", +) db_cursor = db_connection.cursor() - # Get the id's of entries from the future as the database sees it. # We'll delete ids from here as we go, and what's left will be things # which are no longer in Future Business. -db_cursor.execute('select id from future where event_date > CURRENT_DATE()') +db_cursor.execute("select id from future where event_date > CURRENT_DATE()") old_entries = set(db_cursor.fetchall()) for new_entry in get_calendar_events(): @@ -242,16 +257,16 @@ def update(self): positions[event_date] = positions.setdefault(event_date, 0) + 1 row_count = db_cursor.execute( - '''SELECT id, deleted, + """SELECT id, deleted, link_calendar, link_external, body, chamber, event_date, time_start, time_end, committee_name, debate_type, title, witnesses, location FROM future - WHERE id=%s''', - (id,) - ) + WHERE id=%s""", + (id,), + ) if row_count: # We have seen this event before. TODO Compare with current entry, @@ -260,12 +275,25 @@ def update(self): # For some reason the time fields come out as timedelta rather that # time, so need converting. - old_tuple = \ - old_row[0:6] + \ - (old_row[6].isoformat(), ) + \ - ((datetime.datetime.min + old_row[7]).time().isoformat() if old_row[7] is not None else None,) + \ - ((datetime.datetime.min + old_row[8]).time().isoformat() if old_row[8] is not None else None,) + \ - old_row[9:] + old_tuple = ( + old_row[0:6] + + (old_row[6].isoformat(),) + + ( + ( + (datetime.datetime.min + old_row[7]).time().isoformat() + if old_row[7] is not None + else None + ), + ) + + ( + ( + (datetime.datetime.min + old_row[8]).time().isoformat() + if old_row[8] is not None + else None + ), + ) + + old_row[9:] + ) new_tuple = new_entry.get_tuple() @@ -277,11 +305,9 @@ def update(self): new_entry.add() db_cursor.execute( - 'UPDATE future SET pos=%s WHERE id=%s', (positions[event_date], id) - ) - -db_cursor.executemany( - 'UPDATE future SET deleted=1 WHERE id=%s', tuple(old_entries) + "UPDATE future SET pos=%s WHERE id=%s", (positions[event_date], id) ) +db_cursor.executemany("UPDATE future SET deleted=1 WHERE id=%s", tuple(old_entries)) + db_connection.commit() diff --git a/scripts/import_search_suggestions.py b/scripts/import_search_suggestions.py new file mode 100755 index 0000000000..3ff09bca55 --- /dev/null +++ b/scripts/import_search_suggestions.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +""" +import_search_suggestions.py - Import vector search suggestions + +See python scripts/import_search_suggestions.py --help for usage. + +""" + +import re +import sys +from pathlib import Path +from typing import cast +from warnings import filterwarnings + +import MySQLdb +import pandas as pd +import rich_click as click +from pylib.mysociety import config +from rich import print +from rich.prompt import Prompt + +repository_path = Path(__file__).parent.parent + +config.set_file(repository_path / "conf" / "general") + +# suppress warnings about using mysqldb in pandas +filterwarnings( + "ignore", + category=UserWarning, + message=".*pandas only supports SQLAlchemy connectable.*", +) + + +@click.group() +def cli(): + pass + + +def get_twfy_db_connection() -> MySQLdb.Connection: + db_connection = cast( + MySQLdb.Connection, + MySQLdb.connect( + host=config.get("TWFY_DB_HOST"), + db=config.get("TWFY_DB_NAME"), + user=config.get("TWFY_DB_USER"), + passwd=config.get("TWFY_DB_PASS"), + charset="utf8", + ), + ) + return db_connection + + +def df_to_db(df: pd.DataFrame, verbose: bool = False): + """ + add search suggestions to the database + """ + df = df.dropna(how="any") + db_connection = get_twfy_db_connection() + + with db_connection.cursor() as cursor: + # just remove everything and re-insert it all rather than trying to update things + cursor.execute("DELETE FROM vector_search_suggestions") + insert_command = "INSERT INTO vector_search_suggestions (search_term, search_suggestion) VALUES (%s, %s)" + suggestion_data = [ + (row["original_query"], row["match"]) for _, row in df.iterrows() + ] + cursor.executemany(insert_command, suggestion_data) + db_connection.commit() + + if verbose: + print(f"[green]{len(df)} rows updated.") + + db_connection.close() + + +def url_to_db(url: str, verbose: bool = False): + """ + Pipe external URL into the update process. + """ + df = pd.read_csv(url) + + df_to_db(df, verbose=verbose) + + +def file_to_db(file: str, verbose: bool = False): + """ + Pipe file into the update process. + """ + df = pd.read_csv(file) + + df_to_db(df, verbose=verbose) + + +@cli.command() +@click.option( + "--url", + required=False, + default=None, + help="A csv file to update search suggestions from.", +) +@click.option( + "--file", + required=False, + default=None, + help="A csv file to update search suggestions from.", +) +@click.option("--verbose", is_flag=True, help="Show verbose output") +def update_vector_search_suggestions(url: str, file: str, verbose: bool = False): + """ + Update the vector search suggestions + """ + if file: + file_to_db(file, verbose=verbose) + elif url: + url_to_db(url, verbose=verbose) + + +@cli.command() +def count_suggestions(): + """ + for diagnostics to check import has worked + """ + db_connection = get_twfy_db_connection() + with db_connection.cursor() as cursor: + cursor.execute( + "select count(*) as num_suggestions from vector_search_suggestions" + ) + count = cursor.fetchone()[0] + print(f"There are {count} suggestions in the db") + + db_connection.close() + + +def main(): + cli() + + +if __name__ == "__main__": + main() diff --git a/scripts/photo-attribution-import.py b/scripts/photo-attribution-import.py index c967e06e86..bd21754342 100644 --- a/scripts/photo-attribution-import.py +++ b/scripts/photo-attribution-import.py @@ -4,7 +4,7 @@ import json import os import sys -import re + import MySQLdb # Set up commonlib pylib @@ -12,10 +12,13 @@ sys.path.append(os.path.normpath(package_dir + "/../commonlib/pylib")) # And from that, get the config -from mysociety import config +from mysociety import config # noqa:E402 + config.set_file(os.path.abspath(package_dir + "/../conf/general")) -filename = os.path.normpath(config.get('BASEDIR') + config.get('IMAGEPATH') + 'attribution.json') +filename = os.path.normpath( + config.get("BASEDIR") + config.get("IMAGEPATH") + "attribution.json" +) try: data = json.load(open(filename)) except OSError: @@ -23,23 +26,29 @@ sys.exit(0) db_connection = MySQLdb.connect( - host=config.get('TWFY_DB_HOST'), - db=config.get('TWFY_DB_NAME'), - user=config.get('TWFY_DB_USER'), - passwd=config.get('TWFY_DB_PASS'), - charset='utf8', + host=config.get("TWFY_DB_HOST"), + db=config.get("TWFY_DB_NAME"), + user=config.get("TWFY_DB_USER"), + passwd=config.get("TWFY_DB_PASS"), + charset="utf8", ) db_cursor = db_connection.cursor() data_blank = [r for r in data if not r["data_value"]] data_blank = [(r["person_id"], r["data_key"]) for r in data_blank] -db_cursor.executemany("""DELETE FROM personinfo - WHERE person_id=%s AND data_key=%s""", data_blank) +db_cursor.executemany( + """DELETE FROM personinfo + WHERE person_id=%s AND data_key=%s""", + data_blank, +) data = [r for r in data if r["data_value"]] data = [(r["person_id"], r["data_key"], r["data_value"]) for r in data] -db_cursor.executemany("""INSERT INTO personinfo +db_cursor.executemany( + """INSERT INTO personinfo (person_id, data_key, data_value) VALUES (%s, %s, %s) - ON DUPLICATE KEY UPDATE data_value = VALUES(data_value)""", data) + ON DUPLICATE KEY UPDATE data_value = VALUES(data_value)""", + data, +) db_connection.commit() diff --git a/scripts/xml2db.pl b/scripts/xml2db.pl index 0aae12493e..a07ca50790 100755 --- a/scripts/xml2db.pl +++ b/scripts/xml2db.pl @@ -1508,6 +1508,7 @@ sub add_standing_day { 'bill' => sub { $bill = strip_string($_->att('title')); $bill_id = add_bill($bill, $_); + $bill = HTML::Entities::encode_entities($bill); # attributes aren't encoded automatically $majorheadingstate = 1; # Got a }, 'committee' => sub { diff --git a/src/twfy_tools/__init__.py b/src/twfy_tools/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/twfy_tools/common/config.py b/src/twfy_tools/common/config.py new file mode 100644 index 0000000000..576675f6f2 --- /dev/null +++ b/src/twfy_tools/common/config.py @@ -0,0 +1,42 @@ +from pathlib import Path +from typing import Any, Callable + +from pydantic import BaseModel +from pylib.mysociety import config as base_config + +BaseConfigGet = Callable[[str], Any] + +repository_path = Path(__file__).resolve().parents[3] + +base_config.set_file(repository_path / "conf" / "general") + + +class ConfigModel(BaseModel): + """ + Shortcut to reveal to IDE the structure of the configuration. + """ + + TWFY_DB_HOST: str + TWFY_DB_NAME: str + TWFY_DB_USER: str + TWFY_DB_PASS: str + RAWDATA: Path + PWMEMBERS: Path + + @classmethod + def from_php_config(cls, php_config_get: BaseConfigGet): + # iterate over the fields of the model + # and get the value from the php config + + items = {} + + for field in cls.model_fields.keys(): + try: + items[field] = php_config_get(field) + except Exception as e: + raise ValueError(f"Error getting {field} from php config: {e}") + + return cls(**items) + + +config = ConfigModel.from_php_config(base_config.get) diff --git a/src/twfy_tools/db/connection.py b/src/twfy_tools/db/connection.py new file mode 100644 index 0000000000..a540cc0537 --- /dev/null +++ b/src/twfy_tools/db/connection.py @@ -0,0 +1,35 @@ +""" +Basic database connection for TheyWorkForYou database. +""" + +from typing import cast + +import MySQLdb +from sqlalchemy import URL, create_engine + +from ..common.config import config + + +def get_twfy_db_connection() -> MySQLdb.Connection: + db_connection = cast( + MySQLdb.Connection, + MySQLdb.connect( + host=config.TWFY_DB_HOST, + db=config.TWFY_DB_NAME, + user=config.TWFY_DB_USER, + passwd=config.TWFY_DB_PASS, + charset="utf8", + ), + ) + return db_connection + + +engine = create_engine( + URL.create( + drivername="mysql+mysqldb", + username=config.TWFY_DB_USER, + password=config.TWFY_DB_PASS, + host=config.TWFY_DB_HOST, + database=config.TWFY_DB_NAME, + ) +) diff --git a/tests/AlertsPageTest.php b/tests/AlertsPageTest.php index 6d62a781a7..6f70c18d48 100644 --- a/tests/AlertsPageTest.php +++ b/tests/AlertsPageTest.php @@ -15,6 +15,10 @@ private function fetch_page($vars) { return $this->base_fetch_page($vars, 'alert'); } + private function get_page($vars = []) { + return $this->base_fetch_page_user($vars, '1.fbb689a0c092f5534b929d302db2c8a9', 'alert'); + } + public function testFetchPage() { $page = $this->fetch_page([]); $this->assertStringContainsString('TheyWorkForYou Email Alerts', $page); @@ -22,12 +26,18 @@ public function testFetchPage() { public function testKeywordOnly() { $page = $this->fetch_page([ 'alertsearch' => 'elephant']); - $this->assertStringContainsString('Receive alerts when [elephant] is mentioned', $page); + $this->assertStringContainsString('What word or phrase would you like to receive alerts about', $page); + $this->assertStringContainsString('fetch_page([ 'alertsearch' => 'speaker:2']); + $this->assertStringContainsString('Mrs Test Current-MP', $page); } public function testPostCodeOnly() { $page = $this->fetch_page([ 'alertsearch' => 'SE17 3HE']); - $this->assertStringContainsString('when Mrs Test Current-MP', $page); + $this->assertStringContainsString('Mrs Test Current-MP', $page); } public function testPostCodeWithKeyWord() { @@ -49,4 +59,73 @@ public function testPostcodeAndKeywordWithNoSittingMP() { $this->assertStringContainsString('You have used a postcode and something else', $page); $this->assertStringNotContainsString('Did you mean to get alerts for when your MP', $page); } + + public function testBasicKeyWordAlertsCreation() { + $page = $this->fetch_page([ 'step' => 'define']); + $this->assertStringContainsString('What word or phrase would you like to receive alerts about', $page); + $this->assertStringContainsString('fetch_page([ 'step' => 'review', 'email' => 'test@example.org', 'words[]' => 'fish']); + $this->assertStringContainsString('Review Your Alert', $page); + $this->assertStringContainsString('fetch_page([ 'step' => 'confirm', 'email' => 'test@example.org', 'words[]' => 'fish']); + $this->assertStringContainsString('We’re nearly done', $page); + $this->assertStringContainsString('You should receive an email shortly', $page); + } + + public function testMultipleKeyWordAlertsCreation() { + $page = $this->fetch_page([ 'step' => 'define']); + $this->assertStringContainsString('What word or phrase would you like to receive alerts about', $page); + $this->assertStringContainsString('fetch_page([ 'step' => 'review', 'email' => 'test@example.org', 'words[]' => ['fish', 'salmon']]); + $this->assertStringContainsString('Review Your Alert', $page); + $this->assertStringContainsString('assertStringContainsString('fetch_page([ 'step' => 'confirm', 'email' => 'test@example.org', 'words[]' => ['fish', 'salmon']]); + $this->assertStringContainsString('You should receive an email shortly', $page); + } + + public function testMultipleKeyWordAlertsCreationLoggedIn() { + $page = $this->get_page(['step' => 'define']); + $this->assertStringContainsString('What word or phrase would you like to receive alerts about', $page); + $this->assertStringContainsString('get_page([ 'step' => 'review', 'words[]' => ['fish', 'salmon']]); + $this->assertStringContainsString('Review Your Alert', $page); + $this->assertStringContainsString('assertStringContainsString('get_page([ 'step' => 'confirm', 'words[]' => ['fish', 'salmon']]); + $this->assertStringContainsString('You will now receive email alerts on any day when [fish salmon] is mentioned in parliament', $page); + } + + public function testKeyWordAndSectionAlertsCreationLoggedIn() { + $page = $this->get_page(['step' => 'define']); + $this->assertStringContainsString('What word or phrase would you like to receive alerts about', $page); + $this->assertStringContainsString('get_page(['step' => 'review', 'words[]' => 'fish', 'search_section' => 'debates']); + $this->assertStringContainsString('Review Your Alert', $page); + $this->assertStringContainsString('get_page(['step' => 'confirm', 'words[]' => 'fish', 'search_section' => 'debates']); + $this->assertStringContainsString('You will now receive email alerts on any day when [fish] is mentioned in House of Commons debates', $page); + } + + public function testKeyWordAndSpeakerAlertsCreationLoggedIn() { + $page = $this->get_page(['step' => 'define']); + $this->assertStringContainsString('What word or phrase would you like to receive alerts about', $page); + $this->assertStringContainsString('get_page(['step' => 'review', 'words[]' => 'fish', 'representative' => 'Mrs Test Current-MP']); + $this->assertStringContainsString('Review Your Alert', $page); + $this->assertStringContainsString('assertStringContainsString('get_page([ 'step' => 'confirm', 'words[]' => 'fish', 'representative' => 'Mrs Test Current-MP']); + $this->assertStringContainsString('You will now receive email alerts on any day when Mrs Test Current-MP mentions [fish] in parliament', $page); + } } diff --git a/tests/AlertsTest.php b/tests/AlertsTest.php index ce212441e7..263461350a 100644 --- a/tests/AlertsTest.php +++ b/tests/AlertsTest.php @@ -74,6 +74,7 @@ public function testAdd() { 'email' => 'test@theyworkforyou.com', 'keyword' => 'test', 'pc' => 'SW1A 1AA', + 'ignore_speaker_votes' => 0, ]; $response = $ALERT->add($details, false, true); @@ -96,6 +97,7 @@ public function testAddExisting() { 'email' => 'test3@theyworkforyou.com', 'keyword' => 'test3', 'pc' => 'SW1A 1AA', + 'ignore_speaker_votes' => 0, ]; $response = $ALERT->add($details, false, true); @@ -117,6 +119,7 @@ public function testAddDeleted() { 'email' => 'test6@theyworkforyou.com', 'keyword' => 'test6', 'pc' => 'SW1A 1AA', + 'ignore_speaker_votes' => 0, ]; $response = $ALERT->add($details, false, true); @@ -162,6 +165,7 @@ public function testCheckTokenCorrect() { 'id' => 1, 'email' => 'test@theyworkforyou.com', 'criteria' => 'test1', + 'ignore_speaker_votes' => '0', ], $response); } diff --git a/tests/FetchPageTestCase.php b/tests/FetchPageTestCase.php index 352dfbfc47..07b6334544 100644 --- a/tests/FetchPageTestCase.php +++ b/tests/FetchPageTestCase.php @@ -6,7 +6,15 @@ abstract class FetchPageTestCase extends TWFY_Database_TestCase { protected function base_fetch_page($vars, $dir, $page = 'index.php', $req_uri = '') { foreach ($vars as $k => $v) { - $vars[$k] = $k . '=' . urlencode($v); + if (strpos($k, '[') !== false) { + if (is_array($vars[$k])) { + $vars[$k] = $k . '=' . join("&$k=", $vars[$k]); + } else { + $vars[$k] = $k . '=' . urlencode($v); + } + } else { + $vars[$k] = $k . '=' . urlencode($v); + } } if (!$req_uri) { @@ -22,7 +30,15 @@ protected function base_fetch_page($vars, $dir, $page = 'index.php', $req_uri = protected function base_fetch_page_user($vars, $cookie, $dir, $page = 'index.php', $req_uri = '') { foreach ($vars as $k => $v) { - $vars[$k] = $k . '=' . urlencode($v); + if (strpos($k, '[') !== false) { + if (is_array($vars[$k])) { + $vars[$k] = $k . '=' . join("&$k=", $vars[$k]); + } else { + $vars[$k] = $k . '=' . urlencode($v); + } + } else { + $vars[$k] = $k . '=' . urlencode($v); + } } if (!$req_uri) { diff --git a/tests/SearchTest.php b/tests/SearchTest.php index a99e0a6db3..0bd2e38fa7 100644 --- a/tests/SearchTest.php +++ b/tests/SearchTest.php @@ -121,7 +121,7 @@ public function testSearchPage() { public function testSearchPageMP() { $page = $this->fetch_page([ 'q' => 'Mary Smith' ]); $this->assertStringContainsString('Mary Smith', $page); - $this->assertStringContainsString('MP, Amber Valley', $page); + $this->assertMatchesRegularExpression('/MP *for Amber Valley/', $page); } /** @@ -169,9 +169,9 @@ public function testSearchPageMultipleCons() { $page = $this->fetch_page([ 'q' => 'Liverpool' ]); $this->assertStringContainsString('MPs in constituencies matching Liverpool', $page); $this->assertStringContainsString('Susan Brown', $page); - $this->assertStringContainsString('MP, Liverpool, Riverside', $page); + $this->assertMatchesRegularExpression('/MP *for Liverpool, Riverside/', $page); $this->assertStringContainsString('Andrew Jones', $page); - $this->assertStringContainsString('MP, Liverpool, Walton', $page); + $this->assertMatchesRegularExpression('/MP *for Liverpool, Walton/', $page); } /** diff --git a/tests/_fixtures/alerts.xml b/tests/_fixtures/alerts.xml index 45924603e9..64547d5503 100644 --- a/tests/_fixtures/alerts.xml +++ b/tests/_fixtures/alerts.xml @@ -6,6 +6,7 @@ 1 0 0 + 0 token1 test@theyworkforyou.com test1 @@ -15,6 +16,7 @@ 3 0 1 + 0 token3 test3@theyworkforyou.com test3 @@ -24,6 +26,7 @@ 5 0 1 + 0 token5 test5@theyworkforyou.com speaker:1234 @@ -33,6 +36,7 @@ 6 2 1 + 0 token6 test6@theyworkforyou.com test6 diff --git a/tests/_fixtures/alertspage.xml b/tests/_fixtures/alertspage.xml index e237dec30d..b1c65e678c 100644 --- a/tests/_fixtures/alertspage.xml +++ b/tests/_fixtures/alertspage.xml @@ -150,6 +150,14 @@ + + 1 + Test + User + user@example.org + $2y$10$UNelQZqpPpO1jT.f7DLgeOdp.WBT81c5ECvOeTMFeQTBTyq3aCh8q + 1 + diff --git a/www/docs/api/update-card.php b/www/docs/api/update-card.php index bcc77e0361..7b887ef1d1 100644 --- a/www/docs/api/update-card.php +++ b/www/docs/api/update-card.php @@ -13,7 +13,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET') { $setup_intent = \Stripe\SetupIntent::create([ - 'automatic_payment_methods' => ["enabled" => True, "allow_redirects" => "never"], + 'automatic_payment_methods' => ["enabled" => true, "allow_redirects" => "never"], ]); header('Content-Type: application/json'); print json_encode([ diff --git a/www/docs/js/main.js b/www/docs/js/main.js index 6dd194a9f0..64997ca4d0 100644 --- a/www/docs/js/main.js +++ b/www/docs/js/main.js @@ -343,6 +343,28 @@ $(function(){ if (!$('#options').data('advanced')) { $("#options").find(":input").attr("disabled", "disabled"); } + + $('#add-all').on('click', function(e) { + var $add_all = e.currentTarget; + var $selected_related = document.querySelectorAll('input[name="selected_related_terms[]"]'); + if ($add_all.checked) { + $selected_related.forEach(function(input) { + if (input.checked) { + input.setAttribute('data:was_checked', true); + } + input.checked = true; + input.setAttribute('disabled', true); + }); + } else { + $selected_related.forEach(function(input) { + if (!input.getAttribute('data:was_checked')) { + input.checked = false; + } + input.removeAttribute('data:was_checked'); + input.removeAttribute('disabled'); + }); + } + }); }); // Backwards-compatible functions for the click/submit trackers on MP pages @@ -423,6 +445,62 @@ function wrap_error($message){ return ''; } +function createAccordion(triggerSelector, contentSelector) { + var triggers = document.querySelectorAll(triggerSelector); + + triggers.forEach(function(trigger) { + var content = document.querySelector(trigger.getAttribute('href')); + + var openAccordion = function() { + content.style.maxHeight = content.scrollHeight + "px"; // Dynamically calculate height + content.setAttribute('aria-hidden', 'false'); + trigger.setAttribute('aria-expanded', 'true'); + }; + + var closeAccordion = function() { + content.style.maxHeight = null; // Collapse + content.setAttribute('aria-hidden', 'true'); + trigger.setAttribute('aria-expanded', 'false'); + }; + + trigger.addEventListener('click', function(e) { + e.preventDefault(); + + if (content.style.maxHeight) { + closeAccordion(); + } else { + openAccordion(); + } + }); + + // Accessibility + trigger.setAttribute('aria-controls', content.getAttribute('id')); + trigger.setAttribute('aria-expanded', 'false'); + content.setAttribute('aria-hidden', 'true'); + content.style.maxHeight = null; + }); +} + +// Initialize accordion when DOM is loaded +document.addEventListener('DOMContentLoaded', function() { + createAccordion('.js-accordion-button', '.js-accordion-content'); +}); + +// Comfirm deletion of alerts +function confirmDelete() { + var triggers = document.querySelectorAll('.js-confirm-delete'); + + triggers.forEach(function(trigger) { + trigger.addEventListener('click', function(event) { + var message = "Are you sure you want to delete all alerts?"; + if (!confirm(message)) { + event.preventDefault(); + } + }); + }); +} +confirmDelete(); + $(function() { $('#how-often-annually').click(function() { diff --git a/www/docs/regmem/index.php b/www/docs/regmem/index.php index 351c4624cc..568812c2ce 100755 --- a/www/docs/regmem/index.php +++ b/www/docs/regmem/index.php @@ -234,7 +234,7 @@ function _load_file($f) { preg_match('#encoding="([^"]*)"#', $file, $m); $encoding = $m[1]; if ($encoding == 'ISO-8859-1') { - $file = @iconv('iso-8859-1', 'utf-8', $out); + $file = @iconv('iso-8859-1', 'utf-8', $file); } return $file; } diff --git a/www/docs/robots.txt b/www/docs/robots.txt index b284438b37..9ee0f1c858 100644 --- a/www/docs/robots.txt +++ b/www/docs/robots.txt @@ -4,6 +4,7 @@ Disallow: /pwdata/scrapedxml Disallow: /pwdata/cmpages Disallow: /user Disallow: /vote +Disallow: /search User-agent: Slurp Disallow: /pwdata diff --git a/www/docs/style/sass/_twfy-mixins.scss b/www/docs/style/sass/_twfy-mixins.scss index 05e5f4169c..5c7a50ce45 100644 --- a/www/docs/style/sass/_twfy-mixins.scss +++ b/www/docs/style/sass/_twfy-mixins.scss @@ -246,22 +246,29 @@ $weight_bold: 700; .button { background-color: $colour_primary; font-weight: $weight_semibold; - border: 0; + border: 1px solid $colour_primary; @include border-radius(3px); &:hover { background-color: $primary-color-700; } - &:focus { + &:focus-visible { background-color: $color-yellow; color: $body-font-color; } } + button { @extend .button; } +.button--outline { + border: 1px solid $colour_primary; + background-color: $white-text; + color: $colour_primary; +} + .secondary-button, .button--secondary { background-color: $colour_off_white; @@ -276,11 +283,23 @@ button { .button--red, .button--negative { background-color: $color_red; + border: 1px solid $color_red; &:hover { background-color: darken($color_red, 10%); } } +.button--outline-red { + color: $color_red; + border: 1px solid $color_red; + background-color: $white-text; + + &:hover { + color: $white-text; + background-color: $color_red; + } +} + .button--disabled, .button--disabled:hover { background-color: lighten($colour_off_white, 3%); diff --git a/www/docs/style/sass/app.scss b/www/docs/style/sass/app.scss index d356a39cd9..23e527efc5 100644 --- a/www/docs/style/sass/app.scss +++ b/www/docs/style/sass/app.scss @@ -62,10 +62,10 @@ @import url(https://fonts.googleapis.com/css2?family=Manrope:wght@700&family=Merriweather:wght@400;700&display=swap); /* Foundation Icons v 3.0 MIT License */ @font-face { - font-family: "foundation-icons"; - src: url("/style/foundation-icons/foundation-icons.woff") format("woff"); - font-weight: normal; - font-style: normal; + font-family: "foundation-icons"; + src: url("/style/foundation-icons/foundation-icons.woff") format("woff"); + font-weight: normal; + font-style: normal; } .fi-social-facebook:before, @@ -75,17 +75,24 @@ .fi-megaphone:before, .fi-pound:before, .fi-magnifying-glass:before, -.fi-heart:before +.fi-heart:before, +.fi-plus:before, +.fi-play:before, +.fi-pause:before, +.fi-trash:before, +.fi-page-edit:before, +.fi-x:before, +.fi-save:before { - font-family: "foundation-icons"; - font-style: normal; - font-weight: normal; - font-variant: normal; - text-transform: none; - line-height: 1; - -webkit-font-smoothing: antialiased; - display: inline-block; - text-decoration: inherit; + font-family: "foundation-icons"; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; + -webkit-font-smoothing: antialiased; + display: inline-block; + text-decoration: inherit; } // https://github.com/zurb/foundation-icon-fonts/blob/master/_foundation-icons.scss @@ -97,6 +104,13 @@ .fi-pound:before {content: "\f19a"} .fi-magnifying-glass:before {content: "\f16c"} .fi-heart:before { content: "\f159"; } +.fi-plus:before { content: "\f199"; } +.fi-play:before { content: "\f198"; } +.fi-pause:before { content: "\f191"; } +.fi-trash:before { content: "\f204"; } +.fi-page-edit:before { content: "\f184"; } +.fi-x:before { content: "\f217"; } +.fi-save:before { content: "\f1ac"; } html, body { @@ -129,13 +143,13 @@ h3 { } .pull-right { - @media (min-width: $medium-screen) { + @media (min-width: $medium-screen) { float: right; margin-left: 1em; } } .pull-left { - @media (min-width: $medium-screen) { + @media (min-width: $medium-screen) { float: left; margin-left: 1em; } @@ -166,12 +180,12 @@ ul { a { overflow-wrap: break-word; word-wrap: break-word; - + -webkit-hyphens: auto; - -moz-hyphens: auto; - -ms-hyphens: auto; - hyphens: auto; - + -moz-hyphens: auto; + -ms-hyphens: auto; + hyphens: auto; + color: $links; } @@ -198,12 +212,19 @@ a:focus { // for .button elements!! vertical-align: -0.4em; } - + &.tertiary { @include button-style($bg: $links); } } +button { + // For buttons with an icon on the right side + i { + margin-right: 0.15rem; + } +} + /* forms */ .errorlist { @include unstyled-list(); @@ -231,6 +252,8 @@ form { @import "parts/panels"; @import "parts/promo-banner"; +@import "parts/accordion"; +@import "parts/tags"; @import "pages/mp"; @import "pages/topics"; diff --git a/www/docs/style/sass/pages/_alert.scss b/www/docs/style/sass/pages/_alert.scss index 23aeab8d98..048058deb7 100644 --- a/www/docs/style/sass/pages/_alert.scss +++ b/www/docs/style/sass/pages/_alert.scss @@ -87,48 +87,35 @@ } } -.alerts-form { - max-width: 14em; - margin: 0 auto; +// Alert form +.alert-form__section { + margin-bottom: 2.5rem; - label { - width: 100%; - text-align: left; - color: inherit; - font-size: 0.8em; - margin-bottom: 0.4em; - } - - .button { - margin: 0; - } - - input[type="text"] { - border-color: #999; - margin: 0; - height: auto; // allow input to be sized by content's default height - font-size: 1.4em; - line-height: 1em; - padding: 0.2em 0.4em; - border-radius: 3px; + :last-child { + margin-bottom: 0; } +} - @media (min-width: $medium-screen) { - max-width: none; // let the form fill the whole width of the parent +.alert-form__subtitle { + margin-bottom: 0.5rem; +} - p { - display: inline-block; - vertical-align: bottom; // line up the bottom of the three inputs - margin: 0; - } +.alert-form__label { + display: inline; + margin-left: 0.5em; +} - p + p { - margin-left: 1em; - } +.alert__controls { + margin-bottom: 1rem; + form { + display: inline; + } - .button { - vertical-align: bottom; // line up with bottom of parent - } + button { + margin-bottom: 0; + span { + margin-right: 0.2rem; + } } } @@ -187,6 +174,56 @@ padding: 2em 0; } +.alerts-form { + + h3 { + font-weight: 400; + } + + label { + color: $body-font-color; + font-size: 1.1rem; + line-height: 1.2; + margin-bottom: 0.75rem; + } + + input[type="checkbox"], input[type="radio"] { + display: inline-block; + height: 1.5rem; + width: 1.5rem; + margin: 0 0.25rem 0 0; + vertical-align: middle; + + + label { + display: inline-block; + margin-bottom: 0; + line-height: 1.5rem; + vertical-align: middle; + } + } + + input[type="text"], select { + max-width: 400px; + height: 40px; + border-color: $body-font-color; + } + + .checkbox-wrapper { + display: flex; + flex-direction: row; + + input[type="checkbox"], input[type="radio"] { + flex-shrink: 0; + } + } + + .checkbox-group { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + } +} + // If browser supports background-size, // and we have space, give the message an icon @media (min-width: $medium-screen) { @@ -224,6 +261,25 @@ background-color: white; } +.alert-section__header { + display: flex; + flex-direction: row; + flex-wrap: wrap; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; + + button, h2 { + margin-bottom: 0; + margin-top: 0; + } +} + +.alert-section__message { + background-color: #FFFCD9; // very light yellow + padding: 1rem; +} + .alert-section__primary { @include grid-column(12, $collapse: true); padding: 1.5em; @@ -300,54 +356,6 @@ } } -.alert-page-main-inputs { - font-size: 1.2em; - @include clearfix(); - margin: 0.5em 0; - - input[type="text"], - input[type="email"] { - width: 100%; - height: 2.49em; - padding-left: 0.8em; - padding-right: 0.8em; - } - - input[type="text"] { - float: left; - width: 60%; - @include border-radius(3px 0 0 3px); - } - - .button { - float: left; - height: 2.8em; - width: 40%; - @include border-radius(0 3px 3px 0); - margin-bottom: 0; - padding-left: 0; - padding-right: 0; - } - - @media (min-width: 24em) { - input[type="text"] { - width: 70%; - } - .button { - width: 30%; - } - } - - @media (min-width: 36em) { - input[type="text"] { - width: 80%; - } - .button { - width: 20%; - } - } -} - .alert-page-error { display: block; color: $colour_pale_red; @@ -361,9 +369,7 @@ .alert-page-search-tips { color: $light-text; font-size: 0.9em; - border-top: 1px solid $colour_off_white;; - margin-top: 3em; - padding-top: 2em; + padding-top: 5.5rem; h3 { color: inherit; @@ -401,3 +407,100 @@ margin-left: 0.5em; } } + +.alert-meta { + .alert-meta__results { + display: flex; + flex-direction: row; + flex-wrap: wrap; + column-gap: 1rem; + row-gap: 1rem; + align-items: center; + margin-bottom: 1rem; + } + + .alert-meta__item { + border-radius: 0.5rem; + background-color: $primary-color-200; + padding: 0.75rem; + width: fit-content; + + dt { + font-size: 0.7rem; + text-transform: uppercase; + margin-bottom: 0; + } + + dd { + margin-bottom: 0; + font-size: 1.1rem; + } + } + + +} + +// Accordion Keyword +.keyword-alert-accordion { + margin-top: 2rem; + h3 { + font-weight: 400; + font-size: 1rem; + } +} + +.keyword-alert-accordion__button { + @extend .accordion__button; +} + +.keyword-alert-accordion__content { + @extend .accordion__content; +} + +.keyword-alert-accordion__button-content { + display: flex; + flex-direction: column; + align-content: center; + align-items: start; + gap: 0.25rem; + + .keyword-alert-accordion__subtitle { + font-size: 0.75rem; + font-weight: bold; + } +} + +.keyword-alert-accordion__keyword-list { + margin-bottom: 1.5rem; +} + +.keyword-list { + list-style: none; + display: flex; + flex-direction: row; + flex-wrap: wrap; + gap: 0.5rem; + margin-left: 0; + + li { + font-weight: bold; + i { + margin-left: 0.25rem; + } + } +} + +.keyword-alert-accordion__tag, +.keyword-list__tag { + @extend .tag; +} + +.keyword-alert-accordion__tag--included, +.keyword-list__tag--included { + @extend .tag--primary-light; +} + +.keyword-alert-accordion__tag--excluded, +.keyword-list__tag--excluded { + @extend .tag--red; +} diff --git a/www/docs/style/sass/pages/_mp.scss b/www/docs/style/sass/pages/_mp.scss index fd5adf29b6..058e60bfc3 100644 --- a/www/docs/style/sass/pages/_mp.scss +++ b/www/docs/style/sass/pages/_mp.scss @@ -481,7 +481,6 @@ .register { .regmemcategory { - border-top: 1px solid $borders; font-weight: $weight_semibold; margin-top: em-calc(16); margin-bottom: em-calc(8); @@ -790,4 +789,32 @@ a[href^="https://www.publicwhip.org"] { .postcode-mp-image-wrapper { display: block; margin-top: 5px; +} + +h4.interest-summary { + font-weight: bold; + font-size: 1.3em; + margin-top: 1em; + margin-bottom: 1em; +} + +h6.interest-summary { + font-weight: bold; + font-size: 1em; + margin-top: 1em; + margin-bottom: 1em; +} + +.interest-details-list { + font-size: 0.8em; +} + +.child-item-header { + font-weight: bold; + font-size: 1.2em; + margin-top: 1em; + margin-bottom: 1em; +} +.regmemitem > .interest-item { + border-top: 1px solid $borders; } \ No newline at end of file diff --git a/www/docs/style/sass/parts/_accordion.scss b/www/docs/style/sass/parts/_accordion.scss new file mode 100644 index 0000000000..c7ddeaec82 --- /dev/null +++ b/www/docs/style/sass/parts/_accordion.scss @@ -0,0 +1,35 @@ +.accordion__button { + width: 100%; + display: flex; + justify-content: space-between; + text-align: left; + padding: 0.5rem; + font-size: 1rem; + font-weight: 400; + cursor: pointer; + border: none; + color: $body-font-color; + background-color: $primary-color-200; + + &[aria-expanded="true"] { + background-color: lighten($primary-color-100, 6%); + color: $body-font-color; + border: 1px solid $primary-color; + & + .accordion-content{ + max-height: 1000px; + transition: max-height 0.3s ease; + } + + i { + transform: rotate(45deg); + } + } +} + +.accordion__content { + max-height: 0; + overflow: hidden; + transition: max-height 0.3s ease; + padding-left: 1rem; + +} diff --git a/www/docs/style/sass/parts/_tags.scss b/www/docs/style/sass/parts/_tags.scss new file mode 100644 index 0000000000..137b28bd55 --- /dev/null +++ b/www/docs/style/sass/parts/_tags.scss @@ -0,0 +1,17 @@ +.tag { + background-color: #fff; + color: $primary-color; + padding: 0.25rem 0.5rem; + border-radius: 1rem; + font-size: 0.75rem; + + &--primary-light { + background-color: $primary-color-200; + color: $body-font-color; + } + + &--red { + background-color: lighten($color-red, 40%); + color: $body-font-color; + } +} diff --git a/www/includes/easyparliament/alert.php b/www/includes/easyparliament/alert.php index 66493d36e8..eaf6e3314e 100644 --- a/www/includes/easyparliament/alert.php +++ b/www/includes/easyparliament/alert.php @@ -41,6 +41,7 @@ class ALERT { private $alert_id = ""; public $email = ""; public $criteria = ""; // Sets the terms that are used to produce the search results. + public $ignore_speaker_votes = 0; private $db; @@ -90,6 +91,7 @@ public function fetch($confirmed, $deleted) { criteria, registrationtoken, lang, + ignore_speaker_votes, deleted, confirmed FROM alerts @@ -104,6 +106,45 @@ public function fetch($confirmed, $deleted) { return $data; } + public function get_related_terms($term) { + $q = $this->db->query("SELECT + search_suggestion + FROM vector_search_suggestions + WHERE search_term = :term", [ + ':term' => $term, + ]); + + $data = $q->fetchAll(); + $related = []; + foreach ($data as $d) { + $related[] = $d['search_suggestion']; + } + return $related; + } + + public function update($id, $details) { + $criteria = \MySociety\TheyWorkForYou\Utility\Alert::detailsToCriteria($details); + $ignore_speaker_votes = $details['ignore_speaker_votes'] ? 1 : 0; + + $q = $this->db->query("SELECT * FROM alerts + WHERE alert_id = :id", [ + ':id' => $id, + ])->first(); + if ($q) { + $q = $this->db->query("UPDATE alerts SET deleted = 0, criteria = :criteria, ignore_speaker_votes = :ignore_speaker_votes, confirmed = 1 + WHERE alert_id = :id", [ + ":criteria" => $criteria, + ":ignore_speaker_votes" => $ignore_speaker_votes, + ":id" => $id, + ]); + + if ($q->success()) { + return 1; + } + } + return -1; + } + public function add($details, $confirmation_email = false, $instantly_confirm = true) { // Adds a new alert's info into the database. @@ -116,6 +157,7 @@ public function add($details, $confirmation_email = false, $instantly_confirm = // ) $criteria = \MySociety\TheyWorkForYou\Utility\Alert::detailsToCriteria($details); + $ignore_speaker_votes = $details['ignore_speaker_votes'] ? 1 : 0; $q = $this->db->query("SELECT * FROM alerts WHERE email = :email @@ -125,13 +167,14 @@ public function add($details, $confirmation_email = false, $instantly_confirm = ':criteria' => $criteria, ])->first(); if ($q) { - if ($q['deleted']) { - $this->db->query("UPDATE alerts SET deleted=0 + if ($q['deleted'] || $q['ignore_speaker_votes'] != $ignore_speaker_votes) { + $this->db->query("UPDATE alerts SET deleted=0, ignore_speaker_votes=:ignore_speaker_votes WHERE email = :email AND criteria = :criteria AND confirmed=1", [ ':email' => $details['email'], ':criteria' => $criteria, + ':ignore_speaker_votes' => $ignore_speaker_votes, ]); return 1; } else { @@ -140,17 +183,19 @@ public function add($details, $confirmation_email = false, $instantly_confirm = } $q = $this->db->query("INSERT INTO alerts ( - email, criteria, postcode, lang, deleted, confirmed, created + email, criteria, postcode, lang, ignore_speaker_votes, deleted, confirmed, created ) VALUES ( :email, :criteria, :pc, :lang, + :ignore_speaker_votes, '0', '0', NOW() ) ", [ ':email' => $details['email'], ':criteria' => $criteria, + ':ignore_speaker_votes' => $ignore_speaker_votes, ':pc' => $details['pc'], ':lang' => LANGUAGE, ]); @@ -161,6 +206,7 @@ public function add($details, $confirmation_email = false, $instantly_confirm = $this->alert_id = $q->insert_id(); $this->criteria = $criteria; + $this->ignore_speaker_votes = $ignore_speaker_votes; // We have to set the alert's registration token. // This will be sent to them via email, so we can confirm they exist. @@ -343,7 +389,7 @@ public function check_token($token) { return false; } - $q = $this->db->query("SELECT alert_id, email, criteria + $q = $this->db->query("SELECT alert_id, email, criteria, ignore_speaker_votes FROM alerts WHERE alert_id = :alert_id AND registrationtoken = :registration_token @@ -358,6 +404,7 @@ public function check_token($token) { 'id' => $q['alert_id'], 'email' => $q['email'], 'criteria' => $q['criteria'], + 'ignore_speaker_votes' => $q['ignore_speaker_votes'], ]; } diff --git a/www/includes/easyparliament/hansardlist.php b/www/includes/easyparliament/hansardlist.php index e4b0b87d1d..e518071f7f 100644 --- a/www/includes/easyparliament/hansardlist.php +++ b/www/includes/easyparliament/hansardlist.php @@ -1143,6 +1143,7 @@ public function _get_data_by_search($args) { $itemdata['extract'] = $body; $itemdata['listurl'] = '/calendar/?d=' . $itemdata['event_date'] . '#cal' . $itemdata['id']; $itemdata['major'] = 'F'; + $itemdata['hdate'] = $itemdata['event_date']; } else { @@ -1775,7 +1776,7 @@ protected function _get_hansard_data($input) { } } - if (in_array($item['epobject_id'], [15674958, 15674959, 12822764, 12822765])) { + if (in_array($item['epobject_id'], [15674958, 15674959, 12822764, 12822765, 27802084, 27802037])) { global $DATA, $this_page; $DATA->set_page_metadata($this_page, 'robots', 'noindex'); } @@ -2005,13 +2006,29 @@ public function _get_listurl($id_data, $url_args = [], $encode = 'html') { $LISTURL = new \MySociety\TheyWorkForYou\Url('wrans'); } + # From search results we are called as a bare HANSARDLIST + # so do not have $this->url available to us. + if ($id_data['major'] == 6) { + global $DATA; + $minor = $id_data['minor']; + if (isset($this->bill_lookup[$minor])) { + [$title, $session] = $this->bill_lookup[$minor]; + } else { + $qq = $this->db->query('select title, session from bills where id=' . $minor)->first(); + $title = $qq['title']; + $session = $qq['session']; + $this->bill_lookup[$minor] = [$title, $session]; + } + $title = str_replace(' ', '_', $title); + $pbc_url = 'pbc/' . urlencode($session) . '/' . urlencode($title); + } + $fragment = ''; if ($id_data['htype'] == '11' || $id_data['htype'] == '10') { - if ($this->major == 6) { + if ($id_data['major'] == 6) { $id = preg_replace('#^.*?_.*?_#', '', $id_data['gid']); - global $DATA; - $DATA->set_page_metadata('pbc_clause', 'url', 'pbc/' . $this->url . $id); + $DATA->set_page_metadata('pbc_clause', 'url', "$pbc_url/$id"); $LISTURL->remove(['id']); } else { $LISTURL->insert([ 'id' => $id_data['gid'] ]); @@ -2022,7 +2039,6 @@ public function _get_listurl($id_data, $url_args = [], $encode = 'html') { // We use this with the gid of the item itself as an #anchor. $parent_epobject_id = $id_data['subsection_id']; - $minor = $id_data['minor']; // Find the gid of this item's (sub)section. $parent_gid = ''; @@ -2055,18 +2071,8 @@ public function _get_listurl($id_data, $url_args = [], $encode = 'html') { if ($parent_gid != '') { // We have a gid so add to the URL. if ($id_data['major'] == 6) { - if (isset($this->bill_lookup[$minor])) { - [$title, $session] = $this->bill_lookup[$minor]; - } else { - $qq = $this->db->query('select title, session from bills where id=' . $minor)->first(); - $title = $qq['title']; - $session = $qq['session']; - $this->bill_lookup[$minor] = [$title, $session]; - } - $url = "$session/" . urlencode(str_replace(' ', '_', $title)); $parent_gid = preg_replace('#^.*?_.*?_#', '', $parent_gid); - global $DATA; - $DATA->set_page_metadata('pbc_clause', 'url', "pbc/$url/$parent_gid"); + $DATA->set_page_metadata('pbc_clause', 'url', "$pbc_url/$parent_gid"); $LISTURL->remove(['id']); } else { $LISTURL->insert([ 'id' => $parent_gid ]); diff --git a/www/includes/easyparliament/templates/html/alert/_alert_form.php b/www/includes/easyparliament/templates/html/alert/_alert_form.php new file mode 100644 index 0000000000..f9c4739612 --- /dev/null +++ b/www/includes/easyparliament/templates/html/alert/_alert_form.php @@ -0,0 +1,344 @@ +
+
+

+ + + + +

+ +
+ + + + +
+

+ + +
+ + + + + +
+ + +
+ + + + + + $word) { ?> + + + + + + +
+ +
+
+ > + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + + 0) { ?> + + $member) { + $name = member_full_name($member['house'], $member['title'], $member['given_name'], $member['family_name'], $member['lordofname']); + if ($member['constituency']) { + $name .= ' (' . gettext($member['constituency']) . ')'; + } ?> + +
+ + +

+ + +
+ > + +
+
+ + + + +
+ + + + + + + + + + + + + +
+

+
+

+
    + +
  • + +
+
+ + 0 || isset($lastmention)) { ?> +
+
+
+ 0) { ?> +
+
+
+
+ + + +
+
+
30 May 2024
+
+ +
+ +
+ + +
+ +

+ +
+ +
+ + + + + +
+
+ + + + + +
+ + + + + + + + + + + + + + + + + +
+

+ +
+ +

:

+ +

:

+ +
    + +
  • + +
+
+ + +
+

:

+
    + +
  • + +
+
+ + +
+ 0) { ?> +

:

+
    + +
  • + +
+ +

+ +
+ + 0) { ?> +
+

+
    + +
  • + +
+
+ + + 0 || isset($lastmention)) { ?> +
+
+

See mentions for this alert

+ +
+ 0) { ?> +
+
+
+
+ + + +
+
+
30 May 2024
+
+ +
+ + +
+ + +
+ + + + + + +
+ +
+
+ +
+
+

+

+ phrase, be sure to put it in quotes. Also use quotes around a word to avoid stemming (where ‘horse’ would also match ‘horses’).') ?> +

+

+ one term per alert – if you wish to receive alerts on more than one thing, or for more than one person, simply fill in this form as many times as you need, or use boolean OR.') ?> +

+

+ horse or pony are mentioned in Parliament, please fill in this form once with the word horse and then again with the word pony (or you can put horse OR pony with the OR in capitals). Do not put horse, pony as that will only sign you up for alerts where both horse and pony are mentioned.') ?> +

+
+ +
+ +

+

+ +

+ +
    +
  • .
  • +
  • .
  • +
  • Managing email alerts, including how to stop or suspend them.'), 'https://www.mysociety.org/2014/09/04/how-to-manage-your-theyworkforyou-alerts/') ?>
  • +
      +
+
+
diff --git a/www/includes/easyparliament/templates/html/alert/_keyword_alert_list.php b/www/includes/easyparliament/templates/html/alert/_keyword_alert_list.php new file mode 100644 index 0000000000..b2b8239112 --- /dev/null +++ b/www/includes/easyparliament/templates/html/alert/_keyword_alert_list.php @@ -0,0 +1,240 @@ +
+ $alert) { ?> +
+ + +
+ +
+ +
+ +
+
+

Representative alerts

+
+
+ + +
+
+ + +

+ + 0) { ?> +
+

+ +

+ + + +

Alert when is mentioned

+
+ + +
+ + + 0) { ?> +
+

+ +
+ + + +
+
+

+ +

+
+
+ + + + + + + + + +
+
+ + + + + + + + + + + + + + + +
+
+ + +

Alert when is mentioned

+
+ + +
+ +
+ \ No newline at end of file diff --git a/www/includes/easyparliament/templates/html/alert/_list.php b/www/includes/easyparliament/templates/html/alert/_list.php index 3295bcc997..9e2549de96 100644 --- a/www/includes/easyparliament/templates/html/alert/_list.php +++ b/www/includes/easyparliament/templates/html/alert/_list.php @@ -1,27 +1,12 @@ -

+
+

- + +

-
-
- - -
-
+Check here to see your alerts + +
+ + +
diff --git a/www/includes/easyparliament/templates/html/alert/_mp_alert_form.php b/www/includes/easyparliament/templates/html/alert/_mp_alert_form.php new file mode 100644 index 0000000000..3d3076ea6f --- /dev/null +++ b/www/includes/easyparliament/templates/html/alert/_mp_alert_form.php @@ -0,0 +1,88 @@ + 0) { + $member_options = true; ?> +

%s speaks'), _htmlspecialchars($search_term)) ?>

+ + +
+ +

+ + + + +

+ + + + + + +

+ + + + + + + + +

+ +
+
+ > + +
+
+ +

+ + + + + + +

+ + + + + + + + + + + + + + + + + + +
+ diff --git a/www/includes/easyparliament/templates/html/alert/_own_mp_alerts.php b/www/includes/easyparliament/templates/html/alert/_own_mp_alerts.php new file mode 100644 index 0000000000..776b2d1c05 --- /dev/null +++ b/www/includes/easyparliament/templates/html/alert/_own_mp_alerts.php @@ -0,0 +1,69 @@ + + +

+
+
+ + + + + + + + + +
+
+ + + + + + + + + + + + + + + + +
+ +
+ + + +

Alert when is mentioned

+
+ + + +
+ diff --git a/www/includes/easyparliament/templates/html/alert/index.php b/www/includes/easyparliament/templates/html/alert/index.php index 8f4deef191..aed58fbb3e 100644 --- a/www/includes/easyparliament/templates/html/alert/index.php +++ b/www/includes/easyparliament/templates/html/alert/index.php @@ -20,7 +20,7 @@

- +

@@ -83,7 +83,7 @@

- +

@@ -104,6 +104,11 @@

+ +

+

+ +

@@ -115,11 +120,22 @@ - +

+
+

+ +
+
+ + 0) || - ($alertsearch) + !$results && ( + $members || + (isset($constituencies) && count($constituencies) > 0) || + ($alertsearch) + ) ) { /* We need to disambiguate the user's instructions */ $member_options = false; @@ -129,7 +145,7 @@ -

%s speaks'), _htmlspecialchars($alertsearch)) ?>

+

%s speaks'), _htmlspecialchars($search_term)) ?>