Photo-Composition-Designer 0.0.7__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- Photo_Composition_Designer/__init__.py +0 -0
- Photo_Composition_Designer/__main__.py +8 -0
- Photo_Composition_Designer/_version.py +34 -0
- Photo_Composition_Designer/cli/__init__.py +0 -0
- Photo_Composition_Designer/cli/__main__.py +8 -0
- Photo_Composition_Designer/cli/cli.py +106 -0
- Photo_Composition_Designer/common/Anniversaries.py +93 -0
- Photo_Composition_Designer/common/Locations.py +87 -0
- Photo_Composition_Designer/common/MoonPhase.py +85 -0
- Photo_Composition_Designer/common/Photo.py +113 -0
- Photo_Composition_Designer/common/__init__.py +0 -0
- Photo_Composition_Designer/common/logging.py +216 -0
- Photo_Composition_Designer/config/__init__.py +0 -0
- Photo_Composition_Designer/config/config.py +321 -0
- Photo_Composition_Designer/core/__init__.py +0 -0
- Photo_Composition_Designer/core/base.py +383 -0
- Photo_Composition_Designer/gui/GuiLogWriter.py +79 -0
- Photo_Composition_Designer/gui/__init__.py +0 -0
- Photo_Composition_Designer/gui/__main__.py +8 -0
- Photo_Composition_Designer/gui/gui.py +565 -0
- Photo_Composition_Designer/image/CalendarRenderer.py +319 -0
- Photo_Composition_Designer/image/CollageRenderer.py +433 -0
- Photo_Composition_Designer/image/DescriptionRenderer.py +74 -0
- Photo_Composition_Designer/image/MapRenderer.py +101 -0
- Photo_Composition_Designer/image/__init__.py +0 -0
- Photo_Composition_Designer/tools/DescriptionsFileGenerator.py +44 -0
- Photo_Composition_Designer/tools/GeoPlotter.py +211 -0
- Photo_Composition_Designer/tools/Helpers.py +18 -0
- Photo_Composition_Designer/tools/ImageDistributor.py +153 -0
- Photo_Composition_Designer/tools/__init__.py +0 -0
- __init__.py +0 -0
- firewall_handler.py +198 -0
- main.py +146 -0
- path_handler.py +10 -0
- photo_composition_designer-0.0.7.dist-info/METADATA +205 -0
- photo_composition_designer-0.0.7.dist-info/RECORD +40 -0
- photo_composition_designer-0.0.7.dist-info/WHEEL +5 -0
- photo_composition_designer-0.0.7.dist-info/entry_points.txt +3 -0
- photo_composition_designer-0.0.7.dist-info/licenses/LICENSE +24 -0
- photo_composition_designer-0.0.7.dist-info/top_level.txt +5 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import math
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import geopandas as gpd
|
|
5
|
+
import matplotlib.colors as mcolors
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
from shapely.geometry import Point
|
|
8
|
+
|
|
9
|
+
from path_handler import get_base_path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class GeoPlotter:
|
|
13
|
+
"""
|
|
14
|
+
Class for plotting a map section with optional layers such as federal states or bodies of water.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
minimalExtension=5,
|
|
20
|
+
size=(400, 400),
|
|
21
|
+
background_color="black",
|
|
22
|
+
border_color="white",
|
|
23
|
+
line_width=0.4,
|
|
24
|
+
):
|
|
25
|
+
base_path = get_base_path() / "res/maps"
|
|
26
|
+
|
|
27
|
+
countries_shp = base_path / "ne_50m_admin_0_countries/ne_50m_admin_0_countries.shp"
|
|
28
|
+
lakes_shp = (
|
|
29
|
+
base_path / "ne_50m_rivers_lake_centerlines_scale_rank/"
|
|
30
|
+
"ne_50m_rivers_lake_centerlines_scale_rank.shp"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
self.shapefile_path = countries_shp
|
|
34
|
+
self.minimalExtension = minimalExtension
|
|
35
|
+
self.size = size
|
|
36
|
+
self.background_color = self._normalize_color(background_color)
|
|
37
|
+
self.border_color = self._normalize_color(border_color)
|
|
38
|
+
self.line_width = line_width * size[1] / 100
|
|
39
|
+
self.size_marker = 0.25 * size[1] * size[1] / 100
|
|
40
|
+
self.layers = {}
|
|
41
|
+
|
|
42
|
+
# Zusätzliche Layer hinzufügen
|
|
43
|
+
lakes_shp = Path(lakes_shp).resolve()
|
|
44
|
+
self._addLayer("lakes", lakes_shp, color="royalblue", edgecolor="blue", alpha=1.0)
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _normalize_color(color):
|
|
48
|
+
"""
|
|
49
|
+
Normalizes the background color to the range 0-1, if necessary.
|
|
50
|
+
:param color: Color as name, hex value or (R, G, B) tuple in the range 0-255.
|
|
51
|
+
:return: Color in Matplotlib-compatible format.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(color, tuple) and len(color) == 3:
|
|
54
|
+
return tuple(c / 255 for c in color)
|
|
55
|
+
return color
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _create_geodataframe(coordinates: list[tuple[float, float]]):
|
|
59
|
+
"""
|
|
60
|
+
Creates a GeoDataFrame from GPS coordinates.
|
|
61
|
+
:param coordinates: List of (latitude, longitude) tuples.
|
|
62
|
+
:return: GeoDataFrame with points.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
gdf = gpd.GeoDataFrame(
|
|
66
|
+
{"geometry": [Point(lon, lat) for lat, lon in coordinates]},
|
|
67
|
+
crs="EPSG:4326",
|
|
68
|
+
)
|
|
69
|
+
return gdf
|
|
70
|
+
|
|
71
|
+
def _calculate_bounds(self, geo_df):
|
|
72
|
+
"""
|
|
73
|
+
Calculates the boundaries of the map section with a buffer.
|
|
74
|
+
:param geo_df: GeoDataFrame with points.
|
|
75
|
+
:return: Boundaries as (minx, miny, maxx, maxy).
|
|
76
|
+
"""
|
|
77
|
+
bounds = geo_df.total_bounds # (minx, miny, maxx, maxy)
|
|
78
|
+
|
|
79
|
+
# Calculate the height of the section based on the buffer
|
|
80
|
+
|
|
81
|
+
height_deg = abs(bounds[3] - bounds[1]) / 2
|
|
82
|
+
if height_deg < self.minimalExtension / 2:
|
|
83
|
+
height_deg = self.minimalExtension / 2
|
|
84
|
+
height_deg += self.minimalExtension / 8 # minimal margin
|
|
85
|
+
|
|
86
|
+
# Calculate the average width (average width of the points in the GeoDataFrame)
|
|
87
|
+
mid_lat = (
|
|
88
|
+
bounds[1] + bounds[3]
|
|
89
|
+
) / 2 # Average value of the miny and maxy coordinates (latitude)
|
|
90
|
+
mid_lon = (
|
|
91
|
+
bounds[0] + bounds[2]
|
|
92
|
+
) / 2 # Average value of minx and maxx coordinates (longitude)
|
|
93
|
+
|
|
94
|
+
lat_dis_per_deg = 111.32 # Spacing Latitude circle
|
|
95
|
+
# Calculate the width of the section taking into account the latitude
|
|
96
|
+
lon_dis_per_deg = lat_dis_per_deg * math.cos(
|
|
97
|
+
math.radians(mid_lat)
|
|
98
|
+
) # Longitude distance in km
|
|
99
|
+
# Calculate the latitude based on the resolution and the actual longitude distance
|
|
100
|
+
width_deg = lat_dis_per_deg * height_deg * self.size[0] / self.size[1] / lon_dis_per_deg
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
min(bounds[0], mid_lon - width_deg),
|
|
104
|
+
mid_lat - height_deg,
|
|
105
|
+
max(bounds[2], mid_lon + width_deg),
|
|
106
|
+
mid_lat + height_deg,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
def _addLayer(self, name, shapefile_path, color="blue", edgecolor="black", alpha=0.5):
|
|
110
|
+
"""
|
|
111
|
+
Adds a layer such as federal states or bodies of water.
|
|
112
|
+
|
|
113
|
+
:param name: Name of the layer.
|
|
114
|
+
:param shapefile_path: Path to the shapefile of the layer.
|
|
115
|
+
:param color: Fill color of the layer.
|
|
116
|
+
:param edgecolor: Color of the edges.
|
|
117
|
+
:param alpha: Transparency of the layer.
|
|
118
|
+
"""
|
|
119
|
+
gdf = gpd.read_file(shapefile_path)
|
|
120
|
+
self.layers[name] = {
|
|
121
|
+
"gdf": gdf,
|
|
122
|
+
"color": color,
|
|
123
|
+
"edgecolor": edgecolor,
|
|
124
|
+
"alpha": alpha,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def renderMap(self, coordinates: list[tuple[float, float]]):
|
|
128
|
+
"""
|
|
129
|
+
Creates a map section as a plotable object.
|
|
130
|
+
:param coordinates: List of (latitude, longitude) tuples.
|
|
131
|
+
:return: Plottable matplotlib.pyplot object.
|
|
132
|
+
"""
|
|
133
|
+
# Shapefile für Ländergrenzen laden
|
|
134
|
+
world = gpd.read_file(self.shapefile_path)
|
|
135
|
+
size_marker = self.size_marker
|
|
136
|
+
|
|
137
|
+
# Kartengrenzen berechnen
|
|
138
|
+
if not coordinates:
|
|
139
|
+
points_gdf = self._create_geodataframe([(51.0504, 13.7373)])
|
|
140
|
+
self.minimalExtension = 25
|
|
141
|
+
bounds = self._calculate_bounds(points_gdf)
|
|
142
|
+
size_marker = 0
|
|
143
|
+
else:
|
|
144
|
+
# GeoDataFrame für GPS-Punkte erstellen
|
|
145
|
+
points_gdf = self._create_geodataframe(coordinates)
|
|
146
|
+
bounds = self._calculate_bounds(points_gdf)
|
|
147
|
+
|
|
148
|
+
# Karte plotten
|
|
149
|
+
fig, ax = plt.subplots(figsize=(self.size[0] / 100, self.size[1] / 100), tight_layout=True)
|
|
150
|
+
fig.patch.set_facecolor(self.background_color)
|
|
151
|
+
ax.set_facecolor(self.background_color)
|
|
152
|
+
plt.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
|
153
|
+
|
|
154
|
+
# Ländergrenzen plotten
|
|
155
|
+
# Shading of the land area for better contrast to the background
|
|
156
|
+
bg_color = (
|
|
157
|
+
mcolors.to_rgb(self.background_color)
|
|
158
|
+
if isinstance(self.background_color, str)
|
|
159
|
+
else self.background_color
|
|
160
|
+
)
|
|
161
|
+
map_land_color = tuple([x - 0.10 if x >= 0.5 else x + 0.10 for x in bg_color])
|
|
162
|
+
world.plot(
|
|
163
|
+
ax=ax,
|
|
164
|
+
color=map_land_color,
|
|
165
|
+
edgecolor=self.border_color,
|
|
166
|
+
linewidth=self.line_width * 1.0,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# Zusätzliche Layer plotten - außer im großen Europa-Plot
|
|
170
|
+
if coordinates:
|
|
171
|
+
for layer_name, layer_data in self.layers.items():
|
|
172
|
+
layer_data["gdf"].plot(
|
|
173
|
+
ax=ax,
|
|
174
|
+
markersize=self.size_marker,
|
|
175
|
+
color=layer_data["color"],
|
|
176
|
+
edgecolor=layer_data["edgecolor"],
|
|
177
|
+
alpha=layer_data["alpha"],
|
|
178
|
+
linewidth=self.line_width,
|
|
179
|
+
label=layer_name,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
points_gdf.plot(ax=ax, marker="o", color="red", edgecolors="red", markersize=size_marker)
|
|
183
|
+
|
|
184
|
+
# Set axes to the calculated limits
|
|
185
|
+
ax.set_xlim(bounds[0], bounds[2])
|
|
186
|
+
ax.set_ylim(bounds[1], bounds[3])
|
|
187
|
+
|
|
188
|
+
# Remove axes and edge
|
|
189
|
+
ax.axis("off")
|
|
190
|
+
|
|
191
|
+
return plt
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
if __name__ == "__main__":
|
|
195
|
+
output_dir = Path.cwd() # Speichert die Bilder im aktuellen Arbeitsverzeichnis
|
|
196
|
+
|
|
197
|
+
for size in range(100, 900, 200):
|
|
198
|
+
plotter = GeoPlotter(size=(size, size))
|
|
199
|
+
|
|
200
|
+
gps_coords = [
|
|
201
|
+
(51.0504, 13.7373), # Dresden
|
|
202
|
+
(51.3397, 12.3731), # Leipzig
|
|
203
|
+
(50.8278, 12.9214), # Chemnitz
|
|
204
|
+
(51.1079, 17.0441), # Breslau
|
|
205
|
+
(52.5200, 13.5156), # Berlin
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
map_plt = plotter.renderMap(gps_coords)
|
|
209
|
+
|
|
210
|
+
map_plt.savefig(output_dir / f"map_{size}.jpg", bbox_inches=None)
|
|
211
|
+
plt.close()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from PIL import ImageFont
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# --- geometry helpers -----------------------------------------------------
|
|
5
|
+
def mm_to_px(mm: float | int, dpi: float | int = 300) -> int:
|
|
6
|
+
"""Convert millimeters to pixels based on DPI."""
|
|
7
|
+
return int(round(float(mm) * dpi / 25.4))
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# --- font helpers ---------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_font(name: str = "DejaVuSans.ttf", size: int = 10) -> ImageFont.FreeTypeFont:
|
|
14
|
+
"""Load a default TTF font, safely falling back to PIL's default."""
|
|
15
|
+
try:
|
|
16
|
+
return ImageFont.truetype(name, size)
|
|
17
|
+
except Exception:
|
|
18
|
+
return ImageFont.load_default()
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import random
|
|
2
|
+
from collections import defaultdict, deque
|
|
3
|
+
from datetime import datetime, timedelta
|
|
4
|
+
|
|
5
|
+
from Photo_Composition_Designer.common.Photo import Photo
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ImageDistributor:
|
|
9
|
+
def __init__(self, photos: list[Photo], distributions_count: int):
|
|
10
|
+
self.photos = photos
|
|
11
|
+
self.sorted_photos = sorted(photos, key=lambda photo: photo.get_date() or datetime.min)
|
|
12
|
+
|
|
13
|
+
self.distribution_count = distributions_count
|
|
14
|
+
|
|
15
|
+
def distribute_equally(self) -> list[list[Photo]]:
|
|
16
|
+
"""
|
|
17
|
+
Verteilt die Bilder möglichst gleichmäßig auf die gewünschte Anzahl von Gruppen.
|
|
18
|
+
"""
|
|
19
|
+
grouped_images = [] # Liste von Listen für die Verteilung
|
|
20
|
+
|
|
21
|
+
# Anzahl der Bilder pro Gruppe berechnen
|
|
22
|
+
images_per_group = len(self.sorted_photos) // self.distribution_count
|
|
23
|
+
extra_images = (
|
|
24
|
+
len(self.sorted_photos) % self.distribution_count
|
|
25
|
+
) # Falls es nicht exakt aufgeht
|
|
26
|
+
|
|
27
|
+
photo_queue = deque(self.sorted_photos) # Nutzt deque für effiziente Pop-Operationen
|
|
28
|
+
|
|
29
|
+
for i in range(self.distribution_count):
|
|
30
|
+
group_size = images_per_group + (
|
|
31
|
+
1 if i < extra_images else 0
|
|
32
|
+
) # Extra-Bilder gleichmäßig verteilen
|
|
33
|
+
grouped_images.append([photo_queue.popleft() for _ in range(group_size) if photo_queue])
|
|
34
|
+
|
|
35
|
+
return grouped_images
|
|
36
|
+
|
|
37
|
+
def distribute_randomly(self, allowed_delta: int = 1) -> list[list[Photo]]:
|
|
38
|
+
"""
|
|
39
|
+
Ähnlich wie distribute_equally, aber mit einem gewissen Zufallseffekt,
|
|
40
|
+
sodass die Anzahl der Bilder pro Gruppe leicht variieren kann.
|
|
41
|
+
Die Reihenfolge bleibt sortiert, und ein Zufalls-Seed sorgt für Reproduzierbarkeit.
|
|
42
|
+
"""
|
|
43
|
+
random.seed(11) # Fester Seed für reproduzierbare Ergebnisse
|
|
44
|
+
grouped_images = []
|
|
45
|
+
photo_queue = deque(self.sorted_photos) # Nutzt deque für effizientes Entfernen
|
|
46
|
+
remaining_images = len(photo_queue)
|
|
47
|
+
remaining_groups = self.distribution_count
|
|
48
|
+
|
|
49
|
+
for _i in range(self.distribution_count - 1):
|
|
50
|
+
images_per_group = remaining_images // remaining_groups
|
|
51
|
+
group_size = max(
|
|
52
|
+
1, images_per_group + random.choice(range(-allowed_delta, allowed_delta + 1))
|
|
53
|
+
)
|
|
54
|
+
grouped_images.append(
|
|
55
|
+
[photo_queue.popleft() for _ in range(min(group_size, remaining_images))]
|
|
56
|
+
)
|
|
57
|
+
remaining_images -= len(grouped_images[-1])
|
|
58
|
+
remaining_groups -= 1
|
|
59
|
+
|
|
60
|
+
# Alle verbleibenden Bilder der letzten Gruppe zuweisen
|
|
61
|
+
grouped_images.append(list(photo_queue))
|
|
62
|
+
|
|
63
|
+
return grouped_images
|
|
64
|
+
|
|
65
|
+
def distribute_group_matching_dates(
|
|
66
|
+
self, allowed_over_saturation: int = 2, allowed_under_saturation: int = 1
|
|
67
|
+
) -> list[list[Photo]]:
|
|
68
|
+
"""
|
|
69
|
+
Gruppiert Bilder mit demselben Datum zusammen,
|
|
70
|
+
während eine Über- oder Unterfüllung pro Gruppe erlaubt ist.
|
|
71
|
+
Bilder eines Tages können auf mehrere Gruppen aufgeteilt werden.
|
|
72
|
+
"""
|
|
73
|
+
grouped_images = []
|
|
74
|
+
date_groups = defaultdict(list)
|
|
75
|
+
|
|
76
|
+
# Gruppiere Bilder nach ihrem Datum
|
|
77
|
+
for img in self.sorted_photos:
|
|
78
|
+
date_groups[img.get_date()].append(img)
|
|
79
|
+
|
|
80
|
+
sorted_dates = sorted(date_groups.keys())
|
|
81
|
+
remaining_images = sum(len(v) for v in date_groups.values())
|
|
82
|
+
remaining_groups = self.distribution_count
|
|
83
|
+
|
|
84
|
+
while sorted_dates and remaining_groups > 0:
|
|
85
|
+
avg_per_group = remaining_images // remaining_groups
|
|
86
|
+
current_group = []
|
|
87
|
+
current_date = sorted_dates[0]
|
|
88
|
+
|
|
89
|
+
# Füge das erste Bild des Tages hinzu
|
|
90
|
+
current_group.append(date_groups[current_date].pop(0))
|
|
91
|
+
if not date_groups[current_date]:
|
|
92
|
+
sorted_dates.pop(0) # Datum entfernen, wenn keine Bilder mehr vorhanden sind
|
|
93
|
+
|
|
94
|
+
while sorted_dates:
|
|
95
|
+
next_date = sorted_dates[0]
|
|
96
|
+
|
|
97
|
+
if (
|
|
98
|
+
not self.is_same_calendar_week(current_date, next_date)
|
|
99
|
+
and len(current_group) >= avg_per_group - allowed_under_saturation
|
|
100
|
+
):
|
|
101
|
+
# Dieses Datum gehört nicht zur gleichen Woche & Gruppe ist ausreichend voll
|
|
102
|
+
break
|
|
103
|
+
elif len(current_group) >= avg_per_group + allowed_over_saturation:
|
|
104
|
+
# Gruppe hat das erlaubte Maximum erreicht
|
|
105
|
+
break
|
|
106
|
+
else:
|
|
107
|
+
current_group.append(date_groups[next_date].pop(0))
|
|
108
|
+
if not date_groups[next_date]:
|
|
109
|
+
sorted_dates.pop(0)
|
|
110
|
+
|
|
111
|
+
grouped_images.append(current_group)
|
|
112
|
+
remaining_images -= len(current_group)
|
|
113
|
+
remaining_groups -= 1
|
|
114
|
+
|
|
115
|
+
# Falls noch Bilder übrig sind, der letzten Gruppe zuweisen
|
|
116
|
+
if sorted_dates:
|
|
117
|
+
for remaining_date in sorted_dates:
|
|
118
|
+
grouped_images[-1].extend(date_groups[remaining_date])
|
|
119
|
+
|
|
120
|
+
return grouped_images
|
|
121
|
+
|
|
122
|
+
def get_monday_of_same_week(self, date: datetime) -> datetime:
|
|
123
|
+
return date - timedelta(days=date.weekday())
|
|
124
|
+
|
|
125
|
+
def is_same_calendar_week(self, date1: datetime, date2: datetime) -> bool:
|
|
126
|
+
"""
|
|
127
|
+
Prüft, ob zwei Datumswerte in der gleichen Kalenderwoche liegen.
|
|
128
|
+
Berücksichtigt dabei das Jahr und die Kalenderwochen-Nummer.
|
|
129
|
+
"""
|
|
130
|
+
return date1.isocalendar()[:2] == date2.isocalendar()[:2]
|
|
131
|
+
|
|
132
|
+
def distribute_by_week(self, start_date: datetime) -> list[list[Photo]]:
|
|
133
|
+
grouped_images = []
|
|
134
|
+
|
|
135
|
+
for week in range(self.distribution_count):
|
|
136
|
+
week_start = start_date + timedelta(weeks=week)
|
|
137
|
+
week_end = week_start + timedelta(days=6)
|
|
138
|
+
|
|
139
|
+
# Fotos auswählen, die in diese Woche passen
|
|
140
|
+
photos_in_week = [
|
|
141
|
+
photo
|
|
142
|
+
for photo in self.sorted_photos
|
|
143
|
+
if week_start.date() <= photo.get_date().date() <= week_end.date()
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
# Entferne gefilterte Fotos aus der Hauptliste
|
|
147
|
+
self.sorted_photos = [
|
|
148
|
+
photo for photo in self.sorted_photos if photo not in photos_in_week
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
grouped_images.append(photos_in_week)
|
|
152
|
+
|
|
153
|
+
return grouped_images
|
|
File without changes
|
__init__.py
ADDED
|
File without changes
|
firewall_handler.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import socket
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import traceback
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.request
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FirewallHandler:
|
|
12
|
+
"""Handles firewall detection and configuration for network access."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, logger=None):
|
|
15
|
+
self.logger = logger
|
|
16
|
+
self.test_urls = [
|
|
17
|
+
"http://httpbin.org/get",
|
|
18
|
+
"http://www.google.com",
|
|
19
|
+
"https://httpbin.org/get",
|
|
20
|
+
"https://www.google.com",
|
|
21
|
+
]
|
|
22
|
+
self.srtm_urls = [
|
|
23
|
+
"https://cloud.sdsc.edu/v1/AUTH_opentopography/Raster/SRTM_GL1/",
|
|
24
|
+
"http://cloud.sdsc.edu/v1/AUTH_opentopography/Raster/SRTM_GL1/",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
def check_network_access(self, timeout: int = 5) -> bool:
|
|
28
|
+
"""Check if network access is available."""
|
|
29
|
+
self.logger.info("Checking network connectivity...")
|
|
30
|
+
|
|
31
|
+
# First check basic connectivity
|
|
32
|
+
if not self._check_basic_connectivity():
|
|
33
|
+
self.logger.warning("Basic network connectivity failed")
|
|
34
|
+
return False
|
|
35
|
+
|
|
36
|
+
# Then check HTTP access
|
|
37
|
+
if not self._check_http_access(timeout):
|
|
38
|
+
self.logger.warning("HTTP access failed")
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
# Finally check SRTM-specific URLs
|
|
42
|
+
if not self._check_srtm_access(timeout):
|
|
43
|
+
self.logger.warning("SRTM service access failed")
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
self.logger.info("Network access verified successfully")
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
def _check_basic_connectivity(self) -> bool:
|
|
50
|
+
"""Check basic network connectivity using socket."""
|
|
51
|
+
try:
|
|
52
|
+
# Try to connect to DNS servers
|
|
53
|
+
for host, port in [("8.8.8.8", 53), ("1.1.1.1", 53)]:
|
|
54
|
+
try:
|
|
55
|
+
with socket.create_connection((host, port), timeout=3):
|
|
56
|
+
return True
|
|
57
|
+
except (TimeoutError, OSError):
|
|
58
|
+
continue
|
|
59
|
+
return False
|
|
60
|
+
except Exception as e:
|
|
61
|
+
self.logger.debug(f"Basic connectivity check failed: {e}")
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def _check_http_access(self, timeout: int) -> bool:
|
|
65
|
+
"""Check HTTP access to common websites."""
|
|
66
|
+
for url in self.test_urls:
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(url, timeout=timeout) as response:
|
|
69
|
+
if response.status == 200:
|
|
70
|
+
self.logger.debug(f"HTTP access confirmed via {url}")
|
|
71
|
+
return True
|
|
72
|
+
except (TimeoutError, urllib.error.URLError, urllib.error.HTTPError) as e:
|
|
73
|
+
self.logger.debug(f"HTTP access failed for {url}: {e}")
|
|
74
|
+
continue
|
|
75
|
+
return False
|
|
76
|
+
|
|
77
|
+
def _check_srtm_access(self, timeout: int) -> bool:
|
|
78
|
+
"""Check access to SRTM-specific URLs."""
|
|
79
|
+
for url in self.srtm_urls:
|
|
80
|
+
try:
|
|
81
|
+
with urllib.request.urlopen(url, timeout=timeout) as response:
|
|
82
|
+
if response.status in [200, 403]: # 403 is expected for directory listing
|
|
83
|
+
self.logger.debug(f"SRTM access confirmed via {url}")
|
|
84
|
+
return True
|
|
85
|
+
except (TimeoutError, urllib.error.URLError, urllib.error.HTTPError) as e:
|
|
86
|
+
self.logger.debug(f"SRTM access failed for {url}: {e}")
|
|
87
|
+
continue
|
|
88
|
+
return False
|
|
89
|
+
|
|
90
|
+
def handle_firewall_issue(self):
|
|
91
|
+
"""Handle firewall issues by attempting to create rules and inform user."""
|
|
92
|
+
self.logger.info("Attempting to resolve firewall issues...")
|
|
93
|
+
|
|
94
|
+
# Get current executable path
|
|
95
|
+
exe_path = self._get_executable_path()
|
|
96
|
+
if not exe_path:
|
|
97
|
+
self.logger.error("Could not determine executable path")
|
|
98
|
+
self._show_manual_instructions()
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# Try to create firewall rules
|
|
102
|
+
if self._is_windows():
|
|
103
|
+
self._handle_windows_firewall(exe_path)
|
|
104
|
+
else:
|
|
105
|
+
self._handle_linux_firewall(exe_path)
|
|
106
|
+
|
|
107
|
+
def _get_executable_path(self) -> str | None:
|
|
108
|
+
"""Get the path of the current executable."""
|
|
109
|
+
try:
|
|
110
|
+
if getattr(sys, "frozen", False):
|
|
111
|
+
# PyInstaller executable
|
|
112
|
+
return sys.executable
|
|
113
|
+
else:
|
|
114
|
+
# Python script
|
|
115
|
+
return sys.executable
|
|
116
|
+
except Exception as e:
|
|
117
|
+
self.logger.error(f"Error getting executable path: {e}")
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
def _is_windows(self) -> bool:
|
|
121
|
+
"""Check if running on Windows."""
|
|
122
|
+
return os.name == "nt"
|
|
123
|
+
|
|
124
|
+
def _handle_windows_firewall(self, exe_path: str):
|
|
125
|
+
"""Handle Windows firewall configuration."""
|
|
126
|
+
self.logger.info("Attempting to configure Windows Firewall...")
|
|
127
|
+
|
|
128
|
+
app_name = Path(exe_path).stem
|
|
129
|
+
|
|
130
|
+
# Commands to add firewall rules
|
|
131
|
+
commands = [
|
|
132
|
+
# Inbound rule
|
|
133
|
+
[
|
|
134
|
+
"netsh",
|
|
135
|
+
"advfirewall",
|
|
136
|
+
"firewall",
|
|
137
|
+
"add",
|
|
138
|
+
"rule",
|
|
139
|
+
f"name={app_name}_Inbound",
|
|
140
|
+
"dir=in",
|
|
141
|
+
"action=allow",
|
|
142
|
+
f"program={exe_path}",
|
|
143
|
+
"enable=yes",
|
|
144
|
+
],
|
|
145
|
+
# Outbound rule
|
|
146
|
+
[
|
|
147
|
+
"netsh",
|
|
148
|
+
"advfirewall",
|
|
149
|
+
"firewall",
|
|
150
|
+
"add",
|
|
151
|
+
"rule",
|
|
152
|
+
f"name={app_name}_Outbound",
|
|
153
|
+
"dir=out",
|
|
154
|
+
"action=allow",
|
|
155
|
+
f"program={exe_path}",
|
|
156
|
+
"enable=yes",
|
|
157
|
+
],
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
success = False
|
|
161
|
+
for cmd in commands:
|
|
162
|
+
try:
|
|
163
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
164
|
+
self.logger.info(
|
|
165
|
+
f"Firewall rule added successfully: {' '.join(cmd)} result: {result}"
|
|
166
|
+
)
|
|
167
|
+
success = True
|
|
168
|
+
except subprocess.CalledProcessError as e:
|
|
169
|
+
self.logger.warning(f"Failed to add firewall rule: {e}")
|
|
170
|
+
self.logger.debug(f"Command: {' '.join(cmd)}")
|
|
171
|
+
self.logger.debug(f"Error output: {e.stderr}")
|
|
172
|
+
except Exception as e:
|
|
173
|
+
self.logger.error(f"Unexpected error adding firewall rule: {e}")
|
|
174
|
+
self.logger.debug(f"Full traceback:\n{traceback.format_exc()}")
|
|
175
|
+
|
|
176
|
+
if success:
|
|
177
|
+
self.logger.info(
|
|
178
|
+
"Windows Firewall rules added successfully. Please restart the application."
|
|
179
|
+
)
|
|
180
|
+
else:
|
|
181
|
+
self.logger.warning("Failed to automatically configure Windows Firewall.")
|
|
182
|
+
self._show_manual_instructions()
|
|
183
|
+
|
|
184
|
+
def _handle_linux_firewall(self, exe_path: str):
|
|
185
|
+
"""Handle Linux firewall configuration."""
|
|
186
|
+
self.logger.info("Detected Linux system. Firewall configuration may be needed.")
|
|
187
|
+
self._show_manual_instructions()
|
|
188
|
+
|
|
189
|
+
def _show_manual_instructions(self):
|
|
190
|
+
"""Show general manual instructions"""
|
|
191
|
+
self.logger.info(
|
|
192
|
+
"Please check your firewall settings and ensure the application has network access.\n"
|
|
193
|
+
"Refer to the documentation for manual configuration steps."
|
|
194
|
+
"quick solution:"
|
|
195
|
+
"Start your EXE as administrator:"
|
|
196
|
+
"Right-click on EXE → Run as administrator"
|
|
197
|
+
"Or temporarily deactivate the firewall:"
|
|
198
|
+
)
|