Compare commits

..

6 Commits

Author SHA1 Message Date
Christian Merten aae75ce291
perf(finance/admin): remove `is_valid` from list view (#9)
The new unified statement view is very slow in production. This is
caused by the computation of `is_valid` for every entry of the list,
which entails at least one database query per row. Instead of removing
the `is_valid` field from the list view, we could prefetch the related
fields, but I don't think the `is_valid` provides any noticeable benefit
anyway.

We also add pagination to the statement view.
3 months ago
Christian Merten efe20bc721
chore(finance/models): add documentation and test for `validity` (#11)
Currently, the `INVALID_TOTAL` case of the `Statement.validity` method
is tested indirectly via the admin tests and the behavior is hard to
trace back. We therefore add documentation and a targeted test.
3 months ago
Christian Merten 5d4f29be89
feat(finance/admin): unified statement view (#6)
This PR replaces the separate admin views for unsubmitted, submitted and
confirmed statements by one common view. To distinguish the state, we
now display a colored badge in the changelist.

The default permissions for the `Standard` group are changed so that
normal users can continue to view statements they are related to when
these are submitted or confirmed.
3 months ago
Christian Merten 3b8964fbb0
chore(deploy): update urls to github repository (#8) 3 months ago
Christian Merten 3e5f6f0d27
chore: update favicon 3 months ago
Christian Merten 461cb098f0
chore(ci): build and release docker image, build documentation (#7)
We expand the tests CI run to first build the main image, build and
deploy the documentation pages, run the test suite and publish the
docker image in the github docker registry.
4 months ago

@ -0,0 +1,147 @@
name: Build and test
on:
push:
branches:
- main
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
REGISTRY: ghcr.io
APP_IMAGE_NAME: ${{ github.repository }}
NGINX_IMAGE_NAME: ${{ github.repository }}-nginx
jobs:
build-test-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for application image
id: meta-app
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.APP_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Extract metadata for nginx image
id: meta-nginx
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.NGINX_IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build application image
uses: docker/build-push-action@v5
with:
context: .
file: docker/production/Dockerfile
load: true
tags: kompass:test
cache-from: |
type=gha,scope=app-${{ github.ref_name }}
type=gha,scope=app-master
type=gha,scope=app-main
type=registry,ref=ghcr.io/${{ github.repository }}:latest
cache-to: type=gha,mode=max,scope=app-${{ github.ref_name }}
build-args: |
BUILDKIT_INLINE_CACHE=1
- name: Build documentation
run: |
# Create output directory with proper permissions
mkdir -p docs-output
chmod 777 docs-output
# Run sphinx-build inside the container
docker run --rm \
-v ${{ github.workspace }}/docs:/app/docs:ro \
-v ${{ github.workspace }}/docs-output:/app/docs-output \
kompass:test \
bash -c "cd /app/docs && sphinx-build -b html source /app/docs-output"
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs-output
destination_dir: ${{ github.ref == 'refs/heads/main' && '.' || github.ref_name }}
keep_files: true
- name: Run tests
run: make test-only
- name: Check coverage
run: |
COVERAGE=$(python3 -c "import json; data=json.load(open('docker/test/htmlcov/coverage.json')); print(data['totals']['percent_covered'])")
echo "Coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE < 100" | bc -l) )); then
echo "Error: Coverage is ${COVERAGE}%, must be 100%"
exit 1
fi
- name: Tag and push application image
if: github.event_name != 'pull_request'
run: |
# Tag the built image with all required tags
echo "${{ steps.meta-app.outputs.tags }}" | while read -r tag; do
docker tag kompass:test "$tag"
docker push "$tag"
done
- name: Build and push nginx image
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v5
with:
context: docker/production/nginx
file: docker/production/nginx/Dockerfile
push: true
tags: ${{ steps.meta-nginx.outputs.tags }}
labels: ${{ steps.meta-nginx.outputs.labels }}
cache-from: |
type=gha,scope=nginx-${{ github.ref_name }}
type=gha,scope=nginx-master
type=gha,scope=nginx-main
type=registry,ref=ghcr.io/${{ github.repository }}-nginx:latest
cache-to: type=gha,mode=max,scope=nginx-${{ github.ref_name }}
build-args: |
BUILDKIT_INLINE_CACHE=1
- name: Output image tags
if: github.event_name != 'pull_request'
run: |
echo "Application image tags:"
echo "${{ steps.meta-app.outputs.tags }}"
echo ""
echo "Nginx image tags:"
echo "${{ steps.meta-nginx.outputs.tags }}"

@ -1,60 +0,0 @@
name: Tests
on:
push:
branches:
- main
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ hashFiles('requirements.txt', 'docker/test/Dockerfile') }}
restore-keys: |
${{ runner.os }}-buildx-
- name: Build Docker image with cache
run: |
cd docker/test
docker buildx build \
--cache-from type=local,src=/tmp/.buildx-cache \
--cache-to type=local,dest=/tmp/.buildx-cache-new,mode=max \
--load \
-t kompass:test \
-f Dockerfile \
../../
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
- name: Run tests
run: make test-only
- name: Check coverage
run: |
COVERAGE=$(python3 -c "import json; data=json.load(open('docker/test/htmlcov/coverage.json')); print(data['totals']['percent_covered'])")
echo "Coverage: ${COVERAGE}%"
if (( $(echo "$COVERAGE < 100" | bc -l) )); then
echo "Error: Coverage is ${COVERAGE}%, must be 100%"
exit 1
fi

@ -1,6 +1,6 @@
# jdav Kompass
![Build Status](https://github.com/chrisflav/kompass/actions/workflows/test.yml/badge.svg?branch=main)
![Build Status](https://github.com/chrisflav/kompass/actions/workflows/build-docker.yml/badge.svg?branch=main)
Kompass is an administration platform designed for local sections of the Young German Alpine Club. It provides
tools to contact and (automatically) manage members, groups, material, excursions and statements.

@ -13,7 +13,7 @@ services:
master:
<<: *kompass
build:
context: git@git.jdav-hd.merten.dev:digitales/kompass#main
context: https://github.com/chrisflav/kompass.git#main
dockerfile: docker/production/Dockerfile
entrypoint: /app/docker/production/entrypoint-master.sh
volumes:
@ -28,7 +28,7 @@ services:
- "host:10.26.42.1"
nginx:
build: git@git.jdav-hd.merten.dev:digitales/kompass#main:docker/production/nginx
build: https://github.com/chrisflav/kompass.git#main:docker/production/nginx
restart: always
volumes:
- uwsgi_data:/tmp/uwsgi/

@ -22,8 +22,6 @@ ENV PATH="/app/.local/bin:$PATH"
# install requirements
COPY --chown=app:app ./requirements.txt /app/requirements.txt
# we install uwsgi here to check if packages dependencies are resolved, but we don't actually
# need uwsgi in test
RUN pip install coverage uwsgi -r requirements.txt
RUN pip install uwsgi -r requirements.txt
COPY --chown=app:app . /app

@ -1 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.c{fill:#9cc;}.d{fill:#2d5955;}.e{stroke:#2d5955;stroke-width:3.2px;}.e,.f{fill:none;stroke-miterlimit:10;}.g{fill:#666;mix-blend-mode:multiply;}.f{stroke:#666;stroke-width:3.3px;}.h{opacity:.36;}.i{isolation:isolate;}.j{fill:#fff;opacity:.24;}</style></defs><g class="i"><g id="a"><circle class="f" cx="24.31" cy="24.31" r="21.69"/></g><g id="b"><circle class="e" cx="23.69" cy="23.69" r="21.69"/><polygon class="g" points="21.2 22.96 15.21 43.89 27.68 25.98 33.66 5.05 21.2 22.96"/><polygon class="c" points="14.34 43.15 26.8 25.24 20.32 22.23 14.34 43.15"/><polygon class="d" points="32.79 4.32 20.32 22.23 26.8 25.24 32.79 4.32"/><polyline class="h" points="14.34 43.15 26.8 25.24 32.79 4.32"/><circle class="j" cx="23.61" cy="23.64" r="1.55"/></g></g></svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{display:none;}
.st1{display:inline;fill:#254D49;}
.st2{display:inline;opacity:0.27;fill:url(#SVGID_1_);}
.st3{fill:none;stroke:#1B2E2C;stroke-width:3;stroke-miterlimit:10;}
.st4{fill:#1B2E2C;}
.st5{fill:none;stroke:#508480;stroke-width:3.2;stroke-miterlimit:10;}
.st6{fill:#BADDD9;}
.st7{fill:#508480;}
.st8{opacity:0.36;}
.st9{opacity:0.24;fill:#FFFFFF;}
</style>
<g id="Hintergrund_x5F_Uni" class="st0">
<rect class="st1" width="48" height="48"/>
</g>
<g id="Verlauf" class="st0">
<radialGradient id="SVGID_1_" cx="23.348" cy="21.0566" r="25.4002" fx="3.9002" fy="4.7179" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#000000;stop-opacity:0"/>
<stop offset="1" style="stop-color:#000000"/>
</radialGradient>
<circle class="st2" cx="23.6" cy="23.6" r="22.9"/>
</g>
<g id="Logo_x5F_Schatten">
<circle class="st3" cx="24.3" cy="24.3" r="21.7"/>
<polygon class="st4" points="21.4,22.9 15.8,42.4 27.5,25.7 33.2,6.2 "/>
</g>
<g id="LogooVordergrund">
<circle class="st5" cx="23.7" cy="23.7" r="21.7"/>
<g>
<polygon class="st6" points="14.9,41.8 26.6,25.1 20.5,22.2 "/>
<polygon class="st7" points="32.3,5.5 20.5,22.2 26.6,25.1 "/>
<polyline class="st8" points="14.9,41.8 26.6,25.1 32.3,5.5 "/>
<circle class="st9" cx="23.6" cy="23.6" r="1.6"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 873 B

After

Width:  |  Height:  |  Size: 1.6 KiB

@ -65,11 +65,12 @@ def decorate_statement_view(model, perm=None):
@admin.register(Statement)
class StatementAdmin(CommonAdminMixin, admin.ModelAdmin):
fields = ['short_description', 'explanation', 'excursion', 'status']
list_display = ['__str__', 'total_pretty', 'created_by', 'submitted_date', 'is_valid', 'status_badge']
list_display = ['__str__', 'total_pretty', 'created_by', 'submitted_date', 'status_badge']
list_filter = ['status']
search_fields = ('excursion__name', 'short_description')
ordering = ['-submitted_date']
inlines = [BillOnStatementInline]
list_per_page = 25
def has_change_permission(self, request, obj=None):
if obj is None:

@ -206,7 +206,7 @@ msgid "Submitted"
msgstr "Eingereicht"
#: finance/models.py
msgid "Confirmed"
msgid "Completed"
msgstr "Abgewickelt"
#: finance/models.py

@ -55,7 +55,7 @@ class Statement(CommonModel):
UNSUBMITTED, SUBMITTED, CONFIRMED = 0, 1, 2
STATUS_CHOICES = [(UNSUBMITTED, _('In preparation')),
(SUBMITTED, _('Submitted')),
(CONFIRMED, _('Confirmed'))]
(CONFIRMED, _('Completed'))]
STATUS_CSS_CLASS = { SUBMITTED: 'submitted',
CONFIRMED: 'confirmed',
UNSUBMITTED: 'unsubmitted' }
@ -162,6 +162,18 @@ class Statement(CommonModel):
@property
def transaction_issues(self):
"""
Returns a list of critical problems with the currently configured transactions. This is done
by calculating a list of required paiments. From this list, we deduce the total amount
every member should receive (this amount can be negative, due to org fees).
Finally, the amounts are compared to the total amounts paid out by currently setup transactions.
The list of required paiments is generated from:
- All covered bills that have a configured payer.
(Note: This means that `transaction_issues` might return an empty list, but the calculated
total still differs from the transaction total.)
- If the statement is associated with an excursion: allowances, subsidies, LJP paiment and org fee.
"""
needed_paiments = [(b.paid_by, b.amount) for b in self.bill_set.all() if b.costs_covered and b.paid_by]
if self.excursion is not None:
@ -206,6 +218,7 @@ class Statement(CommonModel):
@property
def transactions_match_expenses(self):
"""Returns true iff there are no transaction issues."""
return len(self.transaction_issues) == 0
@property
@ -223,7 +236,11 @@ class Statement(CommonModel):
@property
def total_valid(self):
"""Checks if the calculated total agrees with the total amount of all transactions."""
"""
Checks if the calculated total agrees with the total amount of all transactions.
Note: This is not the same as `transactions_match_expenses`. For details see the
docstring of `transaction_issues`.
"""
total_transactions = 0
for transaction in self.transaction_set.all():
total_transactions += transaction.amount
@ -231,6 +248,19 @@ class Statement(CommonModel):
@property
def validity(self):
"""
Returns the validity status of the statement. This is one of:
- `Statement.VALID`:
Everything is correct.
- `Statement.NON_MATCHING_TRANSACTIONS`:
There is a transaction issue (in the sense of `transaction_issues`).
- `Statement.MISSING_LEDGER`:
At least one transaction has no ledger configured.
- `Statement.INVALID_ALLOWANCE_TO`:
The members receiving allowance don't match the regulations.
- `Statement.INVALID_TOTAL`:
The total amount of transactions differs from the calculated total payout.
"""
if not self.transactions_match_expenses:
return Statement.NON_MATCHING_TRANSACTIONS
if not self.ledgers_configured:

@ -147,6 +147,9 @@ class StatementTestCase(TestCase):
refunded=False
)
self.st6 = Statement.objects.create(night_cost=self.night_cost)
Bill.objects.create(statement=self.st6, amount='42', costs_covered=True)
def test_org_fee(self):
# org fee should be collected if participants are older than 26
self.assertEqual(self.st5.excursion.old_participant_count, 3, 'Calculation of number of old people in excursion is incorrect.')
@ -488,6 +491,11 @@ class StatementTestCase(TestCase):
ljp_contrib = self.st_small.paid_ljp_contributions
self.assertEqual(ljp_contrib, 0)
def test_validity_paid_by_none(self):
# st6 has one covered bill with no payer, so no transaction issues,
# but total transaction amount (= 0) differs from actual total (> 0).
self.assertEqual(self.st6.validity, Statement.INVALID_TOTAL)
class LedgerTestCase(TestCase):
def setUp(self):

@ -1 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48"><defs><style>.c{fill:#9cc;}.d{fill:#2d5955;}.e{stroke:#2d5955;stroke-width:3.2px;}.e,.f{fill:none;stroke-miterlimit:10;}.g{fill:#666;mix-blend-mode:multiply;}.f{stroke:#666;stroke-width:3.3px;}.h{opacity:.36;}.i{isolation:isolate;}.j{fill:#fff;opacity:.24;}</style></defs><g class="i"><g id="a"><circle class="f" cx="24.31" cy="24.31" r="21.69"/></g><g id="b"><circle class="e" cx="23.69" cy="23.69" r="21.69"/><polygon class="g" points="21.2 22.96 15.21 43.89 27.68 25.98 33.66 5.05 21.2 22.96"/><polygon class="c" points="14.34 43.15 26.8 25.24 20.32 22.23 14.34 43.15"/><polygon class="d" points="32.79 4.32 20.32 22.23 26.8 25.24 32.79 4.32"/><polyline class="h" points="14.34 43.15 26.8 25.24 32.79 4.32"/><circle class="j" cx="23.61" cy="23.64" r="1.55"/></g></g></svg>
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.3.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{display:none;}
.st1{display:inline;fill:#254D49;}
.st2{display:inline;opacity:0.27;fill:url(#SVGID_1_);}
.st3{fill:none;stroke:#1B2E2C;stroke-width:3;stroke-miterlimit:10;}
.st4{fill:#1B2E2C;}
.st5{fill:none;stroke:#508480;stroke-width:3.2;stroke-miterlimit:10;}
.st6{fill:#BADDD9;}
.st7{fill:#508480;}
.st8{opacity:0.36;}
.st9{opacity:0.24;fill:#FFFFFF;}
</style>
<g id="Hintergrund_x5F_Uni" class="st0">
<rect class="st1" width="48" height="48"/>
</g>
<g id="Verlauf" class="st0">
<radialGradient id="SVGID_1_" cx="23.348" cy="21.0566" r="25.4002" fx="3.9002" fy="4.7179" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#000000;stop-opacity:0"/>
<stop offset="1" style="stop-color:#000000"/>
</radialGradient>
<circle class="st2" cx="23.6" cy="23.6" r="22.9"/>
</g>
<g id="Logo_x5F_Schatten">
<circle class="st3" cx="24.3" cy="24.3" r="21.7"/>
<polygon class="st4" points="21.4,22.9 15.8,42.4 27.5,25.7 33.2,6.2 "/>
</g>
<g id="LogooVordergrund">
<circle class="st5" cx="23.7" cy="23.7" r="21.7"/>
<g>
<polygon class="st6" points="14.9,41.8 26.6,25.1 20.5,22.2 "/>
<polygon class="st7" points="32.3,5.5 20.5,22.2 26.6,25.1 "/>
<polyline class="st8" points="14.9,41.8 26.6,25.1 32.3,5.5 "/>
<circle class="st9" cx="23.6" cy="23.6" r="1.6"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 873 B

After

Width:  |  Height:  |  Size: 1.6 KiB

@ -3,37 +3,38 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 48 48" style="enable-background:new 0 0 48 48;" xml:space="preserve">
<style type="text/css">
.st0{opacity:0.27;fill:url(#SVGID_1_);}
.st1{fill:none;stroke:#999999;stroke-width:3.3;stroke-miterlimit:10;}
.st2{fill:none;stroke:#508480;stroke-width:3.2;stroke-miterlimit:10;}
.st3{fill:none;stroke:url(#SVGID_00000121983970493329986990000017723393330248815746_);stroke-width:3.2;stroke-miterlimit:10;}
.st4{fill:#999999;}
.st5{fill:#BADDD9;}
.st6{fill:#508480;}
.st7{opacity:0.36;}
.st8{opacity:0.24;fill:#FFFFFF;}
.st0{display:none;}
.st1{display:inline;fill:#254D49;}
.st2{opacity:0.27;fill:url(#SVGID_1_);}
.st3{fill:none;stroke:#1B2E2C;stroke-width:3;stroke-miterlimit:10;}
.st4{fill:#1B2E2C;}
.st5{fill:none;stroke:#508480;stroke-width:3.2;stroke-miterlimit:10;}
.st6{fill:#BADDD9;}
.st7{fill:#508480;}
.st8{opacity:0.36;}
.st9{opacity:0.24;fill:#FFFFFF;}
</style>
<g id="Logo_x5F_Schatten">
<radialGradient id="SVGID_1_" cx="20.8612" cy="20.647" r="22.9444" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#FFFFFF"/>
<g id="Hintergrund_x5F_Uni" class="st0">
<rect class="st1" width="48" height="48"/>
</g>
<g id="Verlauf">
<radialGradient id="SVGID_1_" cx="23.348" cy="21.0566" r="25.4002" fx="3.9002" fy="4.7179" gradientUnits="userSpaceOnUse">
<stop offset="0" style="stop-color:#000000;stop-opacity:0"/>
<stop offset="1" style="stop-color:#000000"/>
</radialGradient>
<circle class="st0" cx="23.6" cy="23.6" r="22.9"/>
<circle class="st1" cx="24.3" cy="24.3" r="21.7"/>
<circle class="st2" cx="23.6" cy="23.6" r="22.9"/>
</g>
<g id="Logo_x5F_Schatten">
<circle class="st3" cx="24.3" cy="24.3" r="21.7"/>
<polygon class="st4" points="21.4,22.9 15.8,42.4 27.5,25.7 33.2,6.2 "/>
</g>
<g id="LogooVordergrund">
<circle class="st2" cx="23.7" cy="23.7" r="21.7"/>
<linearGradient id="SVGID_00000165954078947660943750000008230134672540192957_" gradientUnits="userSpaceOnUse" x1="3.5175" y1="12.0447" x2="43.8685" y2="35.3413">
<stop offset="0" style="stop-color:#BADDD9;stop-opacity:0.1"/>
<stop offset="1" style="stop-color:#508480"/>
</linearGradient>
<circle style="fill:none;stroke:url(#SVGID_00000165954078947660943750000008230134672540192957_);stroke-width:3.2;stroke-miterlimit:10;" cx="23.7" cy="23.7" r="21.7"/>
<polygon class="st4" points="21.2,23 15.2,43.9 27.7,26 33.7,5 "/>
<polygon class="st5" points="14.3,43.2 26.8,25.2 20.3,22.2 "/>
<polygon class="st6" points="32.8,4.3 20.3,22.2 26.8,25.2 "/>
<polyline class="st7" points="14.3,43.2 26.8,25.2 32.8,4.3 "/>
<circle class="st8" cx="23.6" cy="23.6" r="1.5"/>
<circle class="st5" cx="23.7" cy="23.7" r="21.7"/>
<g>
<polygon class="st6" points="14.9,41.8 26.6,25.1 20.5,22.2 "/>
<polygon class="st7" points="32.3,5.5 20.5,22.2 26.6,25.1 "/>
<polyline class="st8" points="14.9,41.8 26.6,25.1 32.3,5.5 "/>
<circle class="st9" cx="23.6" cy="23.6" r="1.6"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Loading…
Cancel
Save